klyro 1.0.6 → 1.0.7

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 (58) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/orchestrator.d.ts +7 -1
  3. package/dist/agent/orchestrator.js +18 -7
  4. package/dist/agent/runtime.d.ts +10 -0
  5. package/dist/agent/runtime.js +70 -13
  6. package/dist/chat.d.ts +10 -0
  7. package/dist/chat.js +39 -7
  8. package/dist/checkpoints/store.d.ts +2 -0
  9. package/dist/checkpoints/store.js +12 -0
  10. package/dist/cli/auth.d.ts +10 -3
  11. package/dist/cli/auth.js +43 -5
  12. package/dist/cli/doctor.js +0 -1
  13. package/dist/cli/eval.js +22 -16
  14. package/dist/cli/hooks.d.ts +21 -1
  15. package/dist/cli/hooks.js +34 -2
  16. package/dist/cli/keychain.d.ts +10 -0
  17. package/dist/cli/keychain.js +86 -0
  18. package/dist/cli/repl.js +54 -17
  19. package/dist/cli/setup.js +3 -2
  20. package/dist/cli/slash/parser.d.ts +1 -1
  21. package/dist/cli/slash/parser.js +6 -3
  22. package/dist/cli/update.d.ts +3 -1
  23. package/dist/cli/update.js +16 -1
  24. package/dist/context/accounting.d.ts +6 -0
  25. package/dist/context/accounting.js +8 -2
  26. package/dist/context/compaction.d.ts +2 -1
  27. package/dist/context/compaction.js +39 -12
  28. package/dist/context/memory.d.ts +11 -0
  29. package/dist/context/memory.js +47 -9
  30. package/dist/eval/harness.d.ts +19 -2
  31. package/dist/eval/harness.js +72 -7
  32. package/dist/index.js +90 -4
  33. package/dist/mcp/auth.d.ts +85 -0
  34. package/dist/mcp/auth.js +249 -0
  35. package/dist/mcp/config.d.ts +28 -0
  36. package/dist/mcp/config.js +65 -0
  37. package/dist/mcp/registry.d.ts +13 -7
  38. package/dist/mcp/registry.js +90 -14
  39. package/dist/mcp/remote.d.ts +7 -0
  40. package/dist/mcp/remote.js +56 -2
  41. package/dist/mcp/sse.d.ts +42 -0
  42. package/dist/mcp/sse.js +310 -0
  43. package/dist/persistence/audit.d.ts +15 -3
  44. package/dist/persistence/audit.js +84 -13
  45. package/dist/persistence/store.d.ts +9 -0
  46. package/dist/persistence/store.js +17 -0
  47. package/dist/policy/engine.js +9 -0
  48. package/dist/providers.js +4 -4
  49. package/dist/tools/shell/shell-exec.d.ts +13 -0
  50. package/dist/tools/shell/shell-exec.js +64 -2
  51. package/dist/tui/app.js +35 -5
  52. package/dist/tui/app.test.js +3 -2
  53. package/dist/tui/approval.js +3 -1
  54. package/dist/tui/scroll-model.d.ts +2 -2
  55. package/dist/tui/scroll-model.js +9 -3
  56. package/dist/tui/tokens.d.ts +8 -11
  57. package/dist/tui/tokens.js +18 -11
  58. package/package.json +1 -1
package/README.md CHANGED
@@ -55,6 +55,19 @@ node dist/index.js chat
55
55
  | `KLYRO_WORKER=0` | Disable subprocess isolation for subagents |
56
56
  | `KLYRO_SESSIONS_DIR`, `KLYRO_UPDATE_CACHE`, `KLYRO_CREDENTIALS_FILE` | Relocatable state (tests + power users) |
57
57
 
58
+ ## New in recent releases
59
+
60
+ - `klyro run --bare` — deterministic runs: skips MCP, hooks, memory/KLYRO.md/context, persistence
61
+ - `klyro mcp trust <name>` / `mcp prompts [server]` / `mcp add <name> <https-url>` — remote MCP + prompt trust
62
+ - `klyro agents lint` — validate `.klyro/agents/*.md` (ids, tool names)
63
+ - `klyro init` — scan-seeded `KLYRO.md` + `.mcp.json` (never overwrites)
64
+ - `klyro update --apply` — opt-in self-apply of the verified update
65
+ - `klyro eval --judge-model <id>` — model-graded rubric scoring
66
+ - Hooks: `matcher` scoping, stdin JSON, `sessionStart`/`sessionEnd`/`stop` events, JSON verdicts
67
+ - Custom agents (`.klyro/agents/*.md`), custom commands (`.klyro/commands/*.md`), vim mode (`/vim`), `@`-file completion
68
+ - Credentials prefer the OS keychain (macOS Keychain, Linux libsecret), 0600 file fallback
69
+ - Headless JSON ends with exactly one stable `kind:result` envelope (parse the LAST line)
70
+
58
71
  ## Documentation
59
72
 
60
73
  | Doc | Purpose |
@@ -53,7 +53,13 @@ export interface AgentDefinition {
53
53
  }
54
54
  /** Default agents a model can delegate to. */
55
55
  export declare const BUILTIN_AGENTS: readonly AgentDefinition[];
56
- /** Compact summary returned to the parent — the child's transcript stays separate. */ export interface ChildSummary {
56
+ /**
57
+ * Layer specialist instructions into a child's system prompt (not the user
58
+ * task). Pure — unit-tested directly. No-op when the def has no prompt.
59
+ */
60
+ export declare function layerSpecialistPrompt(base: RuntimeDeps['systemPrompt'], def: Pick<AgentDefinition, 'id' | 'prompt'>): RuntimeDeps['systemPrompt'];
61
+ /** Compact summary returned to the parent — the child's transcript stays separate. */
62
+ export interface ChildSummary {
57
63
  taskId: string;
58
64
  agentName: string;
59
65
  status: TaskStatus;
@@ -71,6 +71,19 @@ export const BUILTIN_AGENTS = [
71
71
  allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'recent_files'],
72
72
  },
73
73
  ];
74
+ /**
75
+ * Layer specialist instructions into a child's system prompt (not the user
76
+ * task). Pure — unit-tested directly. No-op when the def has no prompt.
77
+ */
78
+ export function layerSpecialistPrompt(base, def) {
79
+ if (!def.prompt)
80
+ return base;
81
+ return (ctx) => {
82
+ const r = base(ctx);
83
+ const block = `\n\n<specialist id="${def.id}">\n${def.prompt}\n</specialist>`;
84
+ return typeof r === 'string' ? r + block : { ...r, system: r.system + block };
85
+ };
86
+ }
74
87
  /**
75
88
  * All agents: builtins plus custom `.klyro/agents/*.md` definitions.
76
89
  * Custom ids win on clash (including overriding a builtin) — the override
@@ -368,7 +381,8 @@ export class AgentOrchestrator {
368
381
  }
369
382
  this.taskMeta.set(record.id, worktree ? { def, dropped: resolved.dropped, worktree, repoCwd } : { def, dropped: resolved.dropped });
370
383
  const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
371
- const childDeps = { ...this.deps, registry: childRegistry };
384
+ const childPromptFn = layerSpecialistPrompt(this.deps.systemPrompt, def);
385
+ const childDeps = { ...this.deps, registry: childRegistry, systemPrompt: childPromptFn };
372
386
  const childRef = {
373
387
  taskId: record.id,
374
388
  ...(parent.taskId !== undefined ? { parentTaskId: parent.taskId } : {}),
@@ -380,11 +394,8 @@ export class AgentOrchestrator {
380
394
  ...(childModel !== undefined ? { model: childModel } : {}),
381
395
  ...(resolved.allowedPaths !== undefined ? { allowedPaths: resolved.allowedPaths } : {}),
382
396
  };
383
- // Specialist instructions from `.klyro/agents/*.md` (or programmatic
384
- // defs) ride with the delegated task on both paths below.
385
- const childTask = def.prompt ? `${def.prompt}\n\n---\n\n${input.task}` : input.task;
386
397
  const childOptions = {
387
- task: childTask,
398
+ task: input.task,
388
399
  cwd: childCwd,
389
400
  model: childModel ?? 'inherit', // model override must reach the adapter (see runtime)
390
401
  maxSteps: def.maxSteps,
@@ -431,7 +442,7 @@ export class AgentOrchestrator {
431
442
  let childOutcome;
432
443
  try {
433
444
  if (useProcessIsolation) {
434
- const sysPrompt = resolveSystemPrompt(this.deps.systemPrompt, { cwd: childCwd });
445
+ const sysPrompt = resolveSystemPrompt(childPromptFn, { cwd: childCwd });
435
446
  // Splice the volatile telemetry suffix into the stable prefix so the
436
447
  // child's provider sees one system string. Telemetry is best-effort
437
448
  // inside the child (it re-emits); the goal here is parity, not
@@ -439,7 +450,7 @@ export class AgentOrchestrator {
439
450
  const systemPrompt = sysPrompt.suffix ? `${sysPrompt.system}\n${sysPrompt.suffix}` : sysPrompt.system;
440
451
  const payload = {
441
452
  cwd: childCwd,
442
- task: childTask,
453
+ task: input.task,
443
454
  // A concrete provider model must reach the child — 'inherit' only
444
455
  // exists to defer resolution inside the parent's run().
445
456
  model: (childModel ?? parent.model),
@@ -131,6 +131,16 @@ export interface RunOptions {
131
131
  store?: import('../persistence/store.js').SessionStore;
132
132
  sessionId?: string;
133
133
  };
134
+ /**
135
+ * Level 10 — tamper-evident audit. When an AuditLog is provided, the
136
+ * runtime writes policy decisions and tool completions into the chained
137
+ * audit stream (complements, does not replace, persistence). Defaults off
138
+ * so callers opt in; `klyro` CLI enables it when a sessions dir exists.
139
+ */
140
+ audit?: {
141
+ log?: import('../persistence/audit.js').AuditLog;
142
+ sessionId?: string;
143
+ };
134
144
  /**
135
145
  * Orchestration context (P0). Present for any agent that is itself managed
136
146
  * by an AgentOrchestrator — so a child knows who its parent is, how deep the
@@ -23,7 +23,7 @@ import { verify, diagnosticForModel } from '../verification/engine.js';
23
23
  import { detectVerifyCommand } from '../verification/auto.js';
24
24
  import { ensureBaseline, getBaseline } from '../verification/baseline.js';
25
25
  import { compressTranscript, totalTokens, calibrateEstimate, transcriptCharLength } from '../context/tokenizer.js';
26
- import { capForModel } from '../context/accounting.js';
26
+ import { capForModel, RESERVE_OUTPUT_TOKENS } from '../context/accounting.js';
27
27
  import { shouldRemind, reminderForTodos } from '../context/memory.js';
28
28
  import { ratesFor, isAnthropicModel } from '../providers/model-info.js';
29
29
  import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
@@ -186,6 +186,16 @@ export async function run(opts, deps) {
186
186
  bus.emit(ev);
187
187
  tracer?.write(ev).catch(() => undefined);
188
188
  };
189
+ // Light audit writer: mirrors policy decisions + tool results into the
190
+ // chained audit log. Best-effort — audit errors must not break the run.
191
+ const auditLog = opts.audit?.log;
192
+ const auditSessionId = opts.audit?.sessionId ?? opts.persist?.sessionId;
193
+ const writeAudit = (ev) => {
194
+ if (!auditLog || !auditSessionId)
195
+ return;
196
+ // fire-and-forget — the log serializes its own chain internally
197
+ void auditLog.write(ev).catch(() => undefined);
198
+ };
189
199
  const closeTracer = async () => {
190
200
  try {
191
201
  await tracer?.close();
@@ -279,6 +289,10 @@ export async function run(opts, deps) {
279
289
  const fileEditCounts = new Map();
280
290
  let stuckTriggers = 0;
281
291
  let stuckAbort = false;
292
+ // Steerable stop: a stop hook's `{"continue":true}` verdict carries one
293
+ // more turn. Consumed once at the completion point, max 3 per run.
294
+ let stopCont = null;
295
+ let stopContUsed = 0;
282
296
  outer: while (steps < maxSteps) {
283
297
  // 5.1 limits: max-cost, max-time
284
298
  if (maxCost !== undefined) {
@@ -358,7 +372,7 @@ export async function run(opts, deps) {
358
372
  const systemForBudget = telemetrySuffix ? `${stableSystem}\n\n${telemetrySuffix}` : stableSystem;
359
373
  // Window-aware ceiling (was a hardcoded 120k that overflowed 8k local
360
374
  // models): size the input budget to the model's context window.
361
- const BUDGET = { total: capForModel(opts.model, 4000), reservedOutput: 4000 };
375
+ const BUDGET = { total: capForModel(opts.model, RESERVE_OUTPUT_TOKENS), reservedOutput: RESERVE_OUTPUT_TOKENS };
362
376
  let reqMessages = transcript;
363
377
  let reqSystem = stableSystem;
364
378
  let reqSuffix = telemetrySuffix;
@@ -464,7 +478,7 @@ export async function run(opts, deps) {
464
478
  telemetry.recordError('overflow_retry');
465
479
  emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'REQUEST_TOO_LARGE', message: 'context overflow — aggressively compacting transcript and retrying the request once' });
466
480
  try {
467
- const compacted = compressTranscript(reqSystem, transcript, { total: 30_000, reservedOutput: 4000 });
481
+ const compacted = compressTranscript(reqSystem, transcript, { total: 30_000, reservedOutput: RESERVE_OUTPUT_TOKENS });
468
482
  if (compacted.messages.length < transcript.length || compacted.dropped > 0) {
469
483
  transcript.splice(0, transcript.length, ...compacted.messages);
470
484
  }
@@ -472,7 +486,7 @@ export async function run(opts, deps) {
472
486
  // Transcript already fits the aggressive budget — force it
473
487
  // strictly smaller so the retry cannot repeat the overflow.
474
488
  const halved = Math.max(4000, Math.floor(totalTokens(reqSystem, transcript) / 2));
475
- const smaller = compressTranscript(reqSystem, transcript, { total: halved, reservedOutput: 4000 });
489
+ const smaller = compressTranscript(reqSystem, transcript, { total: halved, reservedOutput: RESERVE_OUTPUT_TOKENS });
476
490
  transcript.splice(0, transcript.length, ...smaller.messages);
477
491
  }
478
492
  tokenCache = { lastRef: null, lastSystem: undefined, lastCount: 0 };
@@ -603,6 +617,17 @@ export async function run(opts, deps) {
603
617
  return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? withRepairTokens({ ok: false, attempts: verificationAttempts }) : undefined };
604
618
  }
605
619
  if (finalizedCalls.length === 0) {
620
+ // Steerable stop: a stop hook asked for one more turn instead of
621
+ // completing. Consumed once per verdict, max 3 per run.
622
+ if (stopCont !== null && stopContUsed < 3) {
623
+ stopContUsed++;
624
+ const contMsg = { role: 'user', content: [text(`[system note] ${stopCont}`)] };
625
+ stopCont = null;
626
+ transcript.push(contMsg);
627
+ await checkpoint(contMsg);
628
+ emit?.({ kind: 'step_end', step: steps });
629
+ continue;
630
+ }
606
631
  if (hadInvalidTool) {
607
632
  // The model attempted a tool call that failed validation; the error is
608
633
  // already a tool_result in the transcript — loop so the model can
@@ -902,6 +927,7 @@ export async function run(opts, deps) {
902
927
  else {
903
928
  emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: decision.action, reason: decision.reason });
904
929
  }
930
+ writeAudit({ kind: 'policy_decision', sessionId: auditSessionId ?? 'ephemeral', callId: call.id, action: decision.action, ts: Date.now() });
905
931
  if (decision.action === 'deny') {
906
932
  const denyMsg = {
907
933
  role: 'tool',
@@ -990,29 +1016,40 @@ export async function run(opts, deps) {
990
1016
  // Hooks: matching preToolUse hooks run before execution. A non-zero
991
1017
  // exit denies the tool with POLICY_DENIED — the real tool never runs.
992
1018
  // Matchers scope hooks per tool; stdin carries the structured payload.
1019
+ // A structured JSON verdict wins over the exit code: deny blocks with
1020
+ // its message, allow+context attaches model-visible context.
1021
+ const hookContext = [];
993
1022
  const matchingPre = hooksForEvent(runHooks, 'preToolUse', call.name);
994
1023
  if (matchingPre.length > 0) {
995
1024
  for (const hook of matchingPre) {
996
1025
  let exitCode = -1;
997
1026
  let detail = '';
1027
+ let verdict;
998
1028
  try {
999
1029
  const r = await runHook(hook, { toolName: call.name, input: call.input }, { event: 'preToolUse', tool: call.name, input: call.input, sessionId, cwd: opts.cwd });
1000
1030
  exitCode = r.exitCode;
1001
1031
  detail = (r.stderr || r.stdout || '').slice(0, 300);
1032
+ if (r.verdict)
1033
+ verdict = r.verdict;
1002
1034
  }
1003
1035
  catch (err) {
1004
1036
  detail = String(err instanceof Error ? err.message : err).slice(0, 300);
1005
1037
  }
1006
- if (exitCode !== 0) {
1007
- const reason = `hook ${hook.name} denied: ${detail || 'hook failed'}`;
1038
+ if (verdict?.decision === 'deny' || exitCode !== 0) {
1039
+ const reason = `hook ${hook.name} denied: ${verdict?.message || detail || 'hook failed'}`;
1008
1040
  emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: 'deny', reason });
1009
1041
  emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: 'deny', reason });
1010
1042
  const latencyMs = Date.now() - t0;
1011
1043
  return {
1012
1044
  obs: { ok: false, error: { code: 'POLICY_DENIED', message: reason } },
1013
1045
  latencyMs,
1046
+ hookContext,
1014
1047
  };
1015
1048
  }
1049
+ // Allow verdicts may carry model-visible context (sliced at parse).
1050
+ if (verdict?.decision !== 'deny' && typeof verdict?.context === 'string' && verdict.context) {
1051
+ hookContext.push({ hook: hook.name, context: verdict.context });
1052
+ }
1016
1053
  }
1017
1054
  }
1018
1055
  let obs;
@@ -1023,7 +1060,7 @@ export async function run(opts, deps) {
1023
1060
  obs = { ok: false, error: { code: 'EXEC_CRASH', message: err instanceof Error ? err.message : String(err) } };
1024
1061
  }
1025
1062
  const latencyMs = Date.now() - t0;
1026
- return { obs, latencyMs };
1063
+ return { obs, latencyMs, hookContext };
1027
1064
  };
1028
1065
  // 5.2 stuck termination (P0-3): the FIRST detection injects one
1029
1066
  // "change approach" synthetic message for the next model turn; the SECOND
@@ -1044,7 +1081,7 @@ export async function run(opts, deps) {
1044
1081
  };
1045
1082
  // Commit phase: fold one execution result into the transcript, in original
1046
1083
  // call order. The only writer — call sequentially, never concurrently.
1047
- const commitResult = async (call, obs, latencyMs) => {
1084
+ const commitResult = async (call, obs, latencyMs, hookContext = []) => {
1048
1085
  const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
1049
1086
  const toolMsg = {
1050
1087
  role: 'tool',
@@ -1052,6 +1089,16 @@ export async function run(opts, deps) {
1052
1089
  };
1053
1090
  transcript.push(toolMsg);
1054
1091
  await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
1092
+ // Hook-injected context rides as its own user message right after the
1093
+ // tool result (uniform across output shapes — no result surgery).
1094
+ if (hookContext.length > 0) {
1095
+ const note = {
1096
+ role: 'user',
1097
+ content: [text(hookContext.map((h) => `[hook ${h.hook} context]\n${h.context}`).join('\n\n'))],
1098
+ };
1099
+ transcript.push(note);
1100
+ await checkpoint(note);
1101
+ }
1055
1102
  if (obs.ok) {
1056
1103
  telemetry.recordToolCall(call, latencyMs, false);
1057
1104
  if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch') {
@@ -1074,6 +1121,8 @@ export async function run(opts, deps) {
1074
1121
  }
1075
1122
  emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
1076
1123
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
1124
+ // Light audit: every executed call completes into the chained log.
1125
+ writeAudit({ kind: 'tool_call_completed', sessionId: auditSessionId ?? 'ephemeral', callId: call.id, isError: !obs.ok, latencyMs, ts: Date.now() });
1077
1126
  if (obs.ok) {
1078
1127
  const fileChanged = inferFileChanged(call.name, call.input, obs.value);
1079
1128
  if (fileChanged) {
@@ -1133,8 +1182,8 @@ export async function run(opts, deps) {
1133
1182
  const runOne = async (call) => {
1134
1183
  if (!(await gateCall(call)))
1135
1184
  return;
1136
- const { obs, latencyMs } = await execTool(call);
1137
- await commitResult(call, obs, latencyMs);
1185
+ const { obs, latencyMs, hookContext } = await execTool(call);
1186
+ await commitResult(call, obs, latencyMs, hookContext);
1138
1187
  };
1139
1188
  // 3.5 — parallel when every call is concurrencySafe, sequential otherwise.
1140
1189
  // Gate runs sequentially in both paths (approval UI is one-at-a-time).
@@ -1164,7 +1213,7 @@ export async function run(opts, deps) {
1164
1213
  for (let i = 0; i < settled.length; i++) {
1165
1214
  const s = settled[i];
1166
1215
  if (s.status === 'fulfilled') {
1167
- await commitResult(approved[i], s.value.obs, s.value.latencyMs);
1216
+ await commitResult(approved[i], s.value.obs, s.value.latencyMs, s.value.hookContext);
1168
1217
  }
1169
1218
  else {
1170
1219
  await commitResult(approved[i], { ok: false, error: { code: 'EXEC_CRASH', message: String(s.reason) } }, 0);
@@ -1193,8 +1242,11 @@ export async function run(opts, deps) {
1193
1242
  }
1194
1243
  }
1195
1244
  catch { /* ignore — completions are best-effort visibility */ }
1196
- // stop hooks: run once per completed step (blocking, side effects only —
1197
- // output is logged, never injected into the transcript).
1245
+ // stop hooks: run once per completed step (blocking). A structured
1246
+ // `{"continue": true, "message": ...}` verdict asks for one more turn
1247
+ // instead of completing — consumed at the completion point below,
1248
+ // bounded to 3 continuations per run so a hook can't loop forever.
1249
+ stopCont = null; // fresh verdict per step; stale ones never carry over
1198
1250
  for (const hook of hooksForEvent(runHooks, 'stop')) {
1199
1251
  try {
1200
1252
  const r = await runHook(hook, { toolName: '', input: {} }, { event: 'stop', sessionId, cwd: opts.cwd, step: steps, status: 'open' });
@@ -1204,6 +1256,11 @@ export async function run(opts, deps) {
1204
1256
  }
1205
1257
  catch { /* ignore */ }
1206
1258
  }
1259
+ else if (r.verdict?.cont === true && stopContUsed < 3) {
1260
+ stopCont = typeof r.verdict.message === 'string' && r.verdict.message
1261
+ ? r.verdict.message
1262
+ : `stop hook ${hook.name} requested continuation`;
1263
+ }
1207
1264
  }
1208
1265
  catch { /* ignore — stop hooks never fail the turn */ }
1209
1266
  }
package/dist/chat.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface ChatOptions {
20
20
  }
21
21
  /** Strip a trailing slash so we can append /chat/completions cleanly. */
22
22
  export declare function normalizeBaseURL(url: string): string;
23
+ /** True for loopback hostnames (localhost / 127.0.0.0/8 / ::1). */
24
+ export declare function isLoopbackHost(host: string): boolean;
23
25
  /**
24
26
  * Validate that the base URL is HTTPS (or localhost over HTTP for local LLMs).
25
27
  * Refuses to send the bearer token over a plaintext remote connection.
@@ -31,6 +33,14 @@ export declare function normalizeBaseURL(url: string): string;
31
33
  export declare function assertSafeBaseURL(url: string, opts?: {
32
34
  allowInsecure?: boolean;
33
35
  }): void;
36
+ /**
37
+ * Remote MCP URL guard. Mirrors `assertSafeBaseURL`'s fail-closed posture but
38
+ * is named for MCP config/transports so callers do not have to import provider
39
+ * URL text.
40
+ */
41
+ export declare function assertSafeRemoteURL(url: string, opts?: {
42
+ allowInsecure?: boolean;
43
+ }): void;
34
44
  export declare function chat(prompt: string, system: string, modelOverride?: string, opts?: ChatOptions): Promise<void>;
35
45
  /**
36
46
  * Parse SSE frames and write text deltas to stdout. Handles
package/dist/chat.js CHANGED
@@ -18,6 +18,17 @@ const MAX_ERROR_BODY_BYTES = 4_000;
18
18
  export function normalizeBaseURL(url) {
19
19
  return url.replace(/\/+$/, '');
20
20
  }
21
+ /** True for loopback hostnames (localhost / 127.0.0.0/8 / ::1). */
22
+ export function isLoopbackHost(host) {
23
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '');
24
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0' || h === '::')
25
+ return true;
26
+ if (h.startsWith('127.')) {
27
+ const parts = h.split('.');
28
+ return parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
29
+ }
30
+ return false;
31
+ }
21
32
  /**
22
33
  * Validate that the base URL is HTTPS (or localhost over HTTP for local LLMs).
23
34
  * Refuses to send the bearer token over a plaintext remote connection.
@@ -41,14 +52,10 @@ export function assertSafeBaseURL(url, opts) {
41
52
  if (opts?.allowInsecure === true || process.env.KLYRO_ALLOW_INSECURE === '1')
42
53
  return;
43
54
  const host = parsed.hostname.toLowerCase();
44
- // Allow loopback and private networks without extra flag
45
- if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0' || host === '::' || host === '[::]')
55
+ // Allow loopback without flag (shared helper); private LAN ranges keep
56
+ // the provider-path behavior below.
57
+ if (isLoopbackHost(host))
46
58
  return;
47
- if (host.startsWith('127.')) {
48
- const parts = host.split('.');
49
- if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255))
50
- return;
51
- }
52
59
  // Private ranges 10/8, 192.168/16, 172.16-31/12
53
60
  if (/^10\.\d+\.\d+\.\d+$/.test(host))
54
61
  return;
@@ -62,6 +69,31 @@ export function assertSafeBaseURL(url, opts) {
62
69
  }
63
70
  throw new Error(`Unsupported KLYRO_BASE_URL protocol: ${parsed.protocol}`);
64
71
  }
72
+ /**
73
+ * Remote MCP URL guard. Mirrors `assertSafeBaseURL`'s fail-closed posture but
74
+ * is named for MCP config/transports so callers do not have to import provider
75
+ * URL text.
76
+ */
77
+ export function assertSafeRemoteURL(url, opts) {
78
+ let parsed;
79
+ try {
80
+ parsed = new URL(url);
81
+ }
82
+ catch {
83
+ throw new Error(`invalid remote MCP URL: ${url}`);
84
+ }
85
+ if (parsed.protocol === 'https:')
86
+ return;
87
+ if (parsed.protocol === 'http:') {
88
+ if (isLoopbackHost(parsed.hostname))
89
+ return;
90
+ if (opts?.allowInsecure === true || process.env.KLYRO_ALLOW_INSECURE === '1')
91
+ return;
92
+ throw new Error(`Refusing to connect to remote MCP server over plaintext HTTP to ${parsed.hostname}. ` +
93
+ `Use https:// or a loopback URL, or set KLYRO_ALLOW_INSECURE=1 for this terminal only (not recommended).`);
94
+ }
95
+ throw new Error(`unsupported remote MCP URL protocol: ${parsed.protocol}`);
96
+ }
65
97
  export async function chat(prompt, system, modelOverride, opts = {}) {
66
98
  const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL ?? 'https://api.openai.com/v1';
67
99
  const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY;
@@ -21,6 +21,8 @@ export interface CheckpointInfo {
21
21
  }
22
22
  /** Numbered snapshot list for `/checkpoints` and the `/rewind` menu. */
23
23
  export declare function listCheckpointInfo(cwd: string): Promise<CheckpointInfo[]>;
24
+ /** File paths a snapshot would restore (for `/rewind <n> preview`). */
25
+ export declare function snapshotFiles(cwd: string, id: string): Promise<string[]>;
24
26
  export declare function diff(cwd: string, id?: string): Promise<string>;
25
27
  export declare function undo(cwd: string, n?: number): Promise<void>;
26
28
  export declare function rewind(cwd: string): Promise<void>;
@@ -174,6 +174,18 @@ export async function listCheckpointInfo(cwd) {
174
174
  }
175
175
  return out;
176
176
  }
177
+ /** File paths a snapshot would restore (for `/rewind <n> preview`). */
178
+ export async function snapshotFiles(cwd, id) {
179
+ try {
180
+ const meta = JSON.parse(await fs.readFile(path.join(ckptDir(cwd), id, '.meta.json'), 'utf-8'));
181
+ const files = Array.isArray(meta.files) ? meta.files : [];
182
+ const missing = Array.isArray(meta.missing) ? meta.missing.map((f) => `${f} (deleted)`) : [];
183
+ return [...files, ...missing];
184
+ }
185
+ catch {
186
+ return [];
187
+ }
188
+ }
177
189
  export async function diff(cwd, id) {
178
190
  const ckpts = await listCheckpoints(cwd);
179
191
  const target = id ?? ckpts[ckpts.length - 1];
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * 2.2 — klyro login / logout / aliases
3
- * Stores masked key → ~/.klyro/credentials.json 0600
3
+ * Stores masked key → OS keychain when available, else
4
+ * ~/.klyro/credentials.json 0600 (with refuse-on-lax-perms reads).
4
5
  */
5
6
  export declare function credPath(): string;
6
- /** Persist one provider key (0600). Never logs or returns the key. */
7
- export declare function saveKey(provider: string, key: string): Promise<void>;
7
+ /** Persist one provider key: OS keychain when available, else 0600 file. Never logs or returns the key. */
8
+ export declare function saveKey(provider: string, key: string): Promise<'keychain' | 'file'>;
9
+ /**
10
+ * Async key read: OS keychain first (when available), then the 0600 file
11
+ * (with refuse-on-lax-perms). Use in async paths (provider resolution,
12
+ * eval, setup); sync contexts keep `getStoredKey` (file only).
13
+ */
14
+ export declare function getStoredKeyAsync(provider: string): Promise<string | undefined>;
8
15
  /** Which providers have stored keys (names only — never values). */
9
16
  export declare function storedProviders(): string[];
10
17
  export declare const LOGIN_DEFAULTS: Record<string, {
package/dist/cli/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * 2.2 — klyro login / logout / aliases
3
- * Stores masked key → ~/.klyro/credentials.json 0600
3
+ * Stores masked key → OS keychain when available, else
4
+ * ~/.klyro/credentials.json 0600 (with refuse-on-lax-perms reads).
4
5
  */
5
6
  import * as fs from 'node:fs/promises';
6
7
  import * as fsSync from 'node:fs';
@@ -15,21 +16,44 @@ export function credPath() {
15
16
  const home = os.homedir() || process.cwd();
16
17
  return path.join(home, '.klyro', 'credentials.json');
17
18
  }
18
- /** Persist one provider key (0600). Never logs or returns the key. */
19
+ /** Persist one provider key: OS keychain when available, else 0600 file. Never logs or returns the key. */
19
20
  export async function saveKey(provider, key) {
21
+ const trimmed = key.trim();
22
+ try {
23
+ const { keychainSet } = await import('./keychain.js');
24
+ if (await keychainSet(provider, trimmed))
25
+ return 'keychain';
26
+ }
27
+ catch { /* fall through to file */ }
20
28
  const creds = {};
21
29
  try {
22
30
  const raw = await fs.readFile(credPath(), 'utf-8');
23
31
  Object.assign(creds, JSON.parse(raw));
24
32
  }
25
33
  catch { /* ignore */ }
26
- creds[provider] = key.trim();
34
+ creds[provider] = trimmed;
27
35
  await fs.mkdir(path.dirname(credPath()), { recursive: true });
28
36
  await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
29
37
  try {
30
38
  await fs.chmod(credPath(), 0o600);
31
39
  }
32
40
  catch { /* ignore on Windows */ }
41
+ return 'file';
42
+ }
43
+ /**
44
+ * Async key read: OS keychain first (when available), then the 0600 file
45
+ * (with refuse-on-lax-perms). Use in async paths (provider resolution,
46
+ * eval, setup); sync contexts keep `getStoredKey` (file only).
47
+ */
48
+ export async function getStoredKeyAsync(provider) {
49
+ try {
50
+ const { keychainGet } = await import('./keychain.js');
51
+ const v = await keychainGet(provider);
52
+ if (v)
53
+ return v;
54
+ }
55
+ catch { /* fall through to file */ }
56
+ return getStoredKey(provider);
33
57
  }
34
58
  /** Which providers have stored keys (names only — never values). */
35
59
  export function storedProviders() {
@@ -78,8 +102,10 @@ export async function runLogin() {
78
102
  const model = ((await rl.question(`Model [${defs.model}]: `)) || defs.model).trim();
79
103
  const storeProvider = provider === 'local' ? 'openai' : provider;
80
104
  if (key.trim()) {
81
- await saveKey(storeProvider, key);
82
- process.stdout.write(`Saved ${storeProvider} key to ${credPath()} (0600)\n`);
105
+ const where = await saveKey(storeProvider, key);
106
+ process.stdout.write(where === 'keychain'
107
+ ? `Saved ${storeProvider} key to the OS keychain\n`
108
+ : `Saved ${storeProvider} key to ${credPath()} (0600)\n`);
83
109
  }
84
110
  // Persist non-secret settings (merged with existing config, never clobbers).
85
111
  const { loadConfig, saveConfig } = await import('./config.js');
@@ -106,6 +132,18 @@ export async function runLogin() {
106
132
  }
107
133
  }
108
134
  export async function runLogout(provider) {
135
+ // Best-effort keychain removal first (a keychain-held key must not survive
136
+ // a file-only logout).
137
+ try {
138
+ const { keychainDelete } = await import('./keychain.js');
139
+ if (provider)
140
+ await keychainDelete(provider);
141
+ else {
142
+ await keychainDelete('openai');
143
+ await keychainDelete('anthropic');
144
+ }
145
+ }
146
+ catch { /* ignore */ }
109
147
  try {
110
148
  const raw = await fs.readFile(credPath(), 'utf-8');
111
149
  const creds = JSON.parse(raw);
@@ -198,7 +198,6 @@ export async function runDoctor(opts = {}) {
198
198
  process.stdout.write('─'.repeat(40) + '\n');
199
199
  for (const c of checks) {
200
200
  const glyph = c.ok ? '✓' : '✗';
201
- const color = c.ok ? '' : '';
202
201
  process.stdout.write(`${glyph} ${c.name.padEnd(14)} ${c.detail}\n`);
203
202
  }
204
203
  process.stdout.write('─'.repeat(40) + '\n');
package/dist/cli/eval.js CHANGED
@@ -47,6 +47,19 @@ import { builtinRegistry } from '../tools/registry.js';
47
47
  import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
48
48
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
49
49
  export async function runEval(opts) {
50
+ // Live judge adapter (shared by suite + JSONL paths) for `judge.rubric`.
51
+ let judgeAdapter;
52
+ if (opts.judgeModel) {
53
+ const { httpChatAdapter } = await import('../agent/provider-adapter.js');
54
+ const { getStoredKeyAsync } = await import('./auth.js');
55
+ const baseUrl = process.env.KLYRO_BASE_URL;
56
+ const apiKey = process.env.KLYRO_API_KEY ?? (await getStoredKeyAsync('openai')) ?? (await getStoredKeyAsync('anthropic'));
57
+ if (!baseUrl || !apiKey) {
58
+ stderr.write('klyro eval: --judge-model needs KLYRO_BASE_URL and KLYRO_API_KEY (or a stored key)\n');
59
+ return 2;
60
+ }
61
+ judgeAdapter = httpChatAdapter({ baseURL: baseUrl, apiKey });
62
+ }
50
63
  // 5.4 — suite mode: load from evals/fixtures
51
64
  if (opts.suite) {
52
65
  const { runHarness, loadFileFixture } = await import('../eval/harness.js');
@@ -98,12 +111,17 @@ export async function runEval(opts) {
98
111
  stderr.write(`klyro eval: no fixtures for suite ${opts.suite}\n`);
99
112
  return 2;
100
113
  }
101
- // Simple harness for suite: just run check.sh via file fixtures
102
- const { runFileFixture, loadFileFixture: loadFF } = await import('../eval/harness.js');
114
+ // Suite runner: agent-driven fixtures (script.json) execute the real
115
+ // runtime + tools before check.sh; static fixtures just run check.sh.
116
+ // --judge-model threads a live grader into both paths that declare rubrics.
117
+ const { runFileFixture, runAgentFixture, loadFileFixture: loadFF } = await import('../eval/harness.js');
103
118
  const results = [];
104
119
  for (const t of tasks) {
105
120
  const fixture = await loadFF(path.join(fixturesDir, t.id));
106
- const r = await runFileFixture(fixture, { runs: opts.runs, parallel: opts.parallel });
121
+ const judgeOpts = judgeAdapter && opts.judgeModel ? { judgeAdapter, judgeModel: opts.judgeModel } : {};
122
+ const r = fixture.script
123
+ ? await runAgentFixture(fixture, judgeOpts)
124
+ : await runFileFixture(fixture, { runs: opts.runs, parallel: opts.parallel });
107
125
  results.push(r);
108
126
  const tag = r.status === 'pass' ? 'PASS' : 'FAIL';
109
127
  if (opts.output === 'json')
@@ -131,19 +149,7 @@ export async function runEval(opts) {
131
149
  stderr.write('klyro eval: no scenarios in input\n');
132
150
  return 2;
133
151
  }
134
- // Live judge adapter for `judge.rubric` (env endpoint + key; mock otherwise).
135
- let judgeAdapter;
136
- if (opts.judgeModel) {
137
- const { httpChatAdapter } = await import('../agent/provider-adapter.js');
138
- const { getStoredKey } = await import('./auth.js');
139
- const baseUrl = process.env.KLYRO_BASE_URL;
140
- const apiKey = process.env.KLYRO_API_KEY ?? getStoredKey('openai') ?? getStoredKey('anthropic');
141
- if (!baseUrl || !apiKey) {
142
- stderr.write('klyro eval: --judge-model needs KLYRO_BASE_URL and KLYRO_API_KEY (or a stored key)\n');
143
- return 2;
144
- }
145
- judgeAdapter = httpChatAdapter({ baseURL: baseUrl, apiKey });
146
- }
152
+ // Live judge adapter was built at the top (shared with suite mode).
147
153
  const results = [];
148
154
  for (const sc of scenarios) {
149
155
  const start = Date.now();