klyro 0.1.53 → 0.1.55

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 (50) hide show
  1. package/dist/agent/anthropic-adapter.js +22 -2
  2. package/dist/agent/registry.js +11 -1
  3. package/dist/agent/retry.d.ts +5 -0
  4. package/dist/agent/retry.js +28 -1
  5. package/dist/agent/runtime.d.ts +2 -0
  6. package/dist/agent/runtime.js +48 -35
  7. package/dist/chat.js +2 -2
  8. package/dist/checkpoints/store.js +86 -7
  9. package/dist/cli/config.js +13 -1
  10. package/dist/cli/repl.js +200 -36
  11. package/dist/cli/run.js +88 -69
  12. package/dist/context/klyro-md.d.ts +5 -0
  13. package/dist/context/klyro-md.js +28 -6
  14. package/dist/context/tokenizer.js +8 -1
  15. package/dist/index.js +49 -14
  16. package/dist/persistence/session.d.ts +2 -0
  17. package/dist/persistence/session.js +11 -5
  18. package/dist/persistence/store.d.ts +7 -0
  19. package/dist/persistence/store.js +77 -28
  20. package/dist/policy/engine.d.ts +10 -1
  21. package/dist/policy/engine.js +33 -2
  22. package/dist/tools/fs/apply-patch.d.ts +16 -1
  23. package/dist/tools/fs/apply-patch.js +163 -75
  24. package/dist/tools/fs/edit-file.d.ts +13 -0
  25. package/dist/tools/fs/edit-file.js +96 -54
  26. package/dist/tools/fs/list-dir.d.ts +2 -0
  27. package/dist/tools/fs/list-dir.js +7 -5
  28. package/dist/tools/fs/multi-edit.js +12 -1
  29. package/dist/tools/fs/read-file.js +1 -1
  30. package/dist/tools/fs/read-history.d.ts +2 -2
  31. package/dist/tools/fs/read-history.js +39 -7
  32. package/dist/tools/fs/write-file.js +1 -1
  33. package/dist/tools/normalize.js +11 -1
  34. package/dist/tools/shell/background.d.ts +5 -0
  35. package/dist/tools/shell/background.js +29 -0
  36. package/dist/tools/shell/shell-exec.d.ts +9 -0
  37. package/dist/tools/shell/shell-exec.js +54 -8
  38. package/dist/tools/verify/run-verify.js +2 -2
  39. package/dist/tui/app.js +10 -5
  40. package/dist/tui/app.test.js +42 -0
  41. package/dist/tui/approval.d.ts +1 -1
  42. package/dist/tui/approval.js +20 -5
  43. package/dist/tui/diff.d.ts +5 -19
  44. package/dist/tui/diff.js +9 -25
  45. package/dist/tui/status.d.ts +4 -8
  46. package/dist/tui/status.js +8 -17
  47. package/dist/tui/transcript.d.ts +13 -20
  48. package/dist/tui/transcript.js +9 -74
  49. package/dist/verification/engine.js +4 -4
  50. package/package.json +1 -1
package/dist/cli/repl.js CHANGED
@@ -14,7 +14,7 @@ import { httpChatAdapter } from '../agent/provider-adapter.js';
14
14
  import { anthropicAdapter } from '../agent/anthropic-adapter.js';
15
15
  import { run } from '../agent/runtime.js';
16
16
  import { builtinRegistry } from '../tools/registry.js';
17
- import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
17
+ import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
18
18
  import { buildLevel6Context } from '../context/level6.js';
19
19
  import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
20
20
  import { TuiApprovalBridge } from '../tui/approval.js';
@@ -68,7 +68,9 @@ export async function startRepl(opts = {}) {
68
68
  let model = opts.model ?? resolved.model;
69
69
  let cwd = opts.cwd ?? process.cwd();
70
70
  const registry = builtinRegistry();
71
- const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
71
+ // Clone: /mode and /sandbox mutate this config — it must never leak into
72
+ // the shared DEFAULT_POLICY_CONFIG across sessions.
73
+ const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
72
74
  const providerKind = inferProviderFromBaseURL(baseUrl);
73
75
  // Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
74
76
  // via proxy — don't try anthropic adapter with empty key (would 401).
@@ -252,7 +254,15 @@ export async function startRepl(opts = {}) {
252
254
  const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
253
255
  const dir = nodePath.join(home, '.klyro');
254
256
  fsSync.mkdirSync(dir, { recursive: true });
255
- fsSync.appendFileSync(nodePath.join(dir, 'debug.log'), line + '\n');
257
+ const logFile = nodePath.join(dir, 'debug.log');
258
+ // Rotation: never let the debug log grow without bound (2 MiB cap).
259
+ try {
260
+ if (fsSync.statSync(logFile).size > 2 * 1024 * 1024) {
261
+ fsSync.renameSync(logFile, `${logFile}.1`);
262
+ }
263
+ }
264
+ catch { /* missing — fine */ }
265
+ fsSync.appendFileSync(logFile, line + '\n');
256
266
  }
257
267
  catch { /* ignore */ }
258
268
  };
@@ -327,6 +337,42 @@ export async function startRepl(opts = {}) {
327
337
  }
328
338
  catch { /* best-effort */ }
329
339
  }
340
+ /** Extract display text from a read_file result ({lines[]} shape). */
341
+ function readFileBody(value) {
342
+ const v = value;
343
+ if (typeof v?.content === 'string')
344
+ return v.content;
345
+ if (Array.isArray(v?.lines))
346
+ return v.lines.join('\n');
347
+ if (typeof v?.text === 'string')
348
+ return v.text;
349
+ return JSON.stringify(v ?? '');
350
+ }
351
+ /**
352
+ * Build the attached-files context block (/attach, @path, /mention feed
353
+ * this — previously they were display-only and never reached the agent).
354
+ * Re-read fresh at prompt time (5 files, 2k chars each, 8k total cap).
355
+ */
356
+ async function buildAttachedBlock() {
357
+ if (attachedFiles.size === 0)
358
+ return '';
359
+ const parts = [];
360
+ let total = 0;
361
+ for (const file of [...attachedFiles.keys()].slice(0, 5)) {
362
+ try {
363
+ const r = await registry.execute('read_file', { path: file }, { cwd, env: process.env, nonInteractive: true });
364
+ if (!r.ok)
365
+ continue;
366
+ const body = readFileBody(r.value).slice(0, 2000);
367
+ if (total + body.length > 8000)
368
+ break;
369
+ total += body.length;
370
+ parts.push(`<attached file="${file}">\n${body}\n</attached>`);
371
+ }
372
+ catch { /* ignore */ }
373
+ }
374
+ return parts.length > 0 ? `\n\n${parts.join('\n\n')}` : '';
375
+ }
330
376
  /** Truncation budget honoring /verbose and /raw. */
331
377
  function outCap(s) {
332
378
  const cap = rawMode ? 12000 : verboseMode ? 8000 : 4000;
@@ -338,10 +384,11 @@ export async function startRepl(opts = {}) {
338
384
  throw new Error(r.error.message ?? 'shell failed');
339
385
  return r.value;
340
386
  }
341
- /** Read-only LLM answer (no tools) — powers /ask and /explain. */
387
+ /** Read-only LLM answer (no tools) — powers /ask, /explain, /compact. */
342
388
  async function answerReadOnly(question, context) {
343
389
  const sys = systemPromptFn({ cwd, telemetry: '' }) + '\n\nAnswer read-only: do not call tools, do not edit files.';
344
- const userText = context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question;
390
+ const attached = await buildAttachedBlock();
391
+ const userText = (context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question) + attached;
345
392
  const req = {
346
393
  model,
347
394
  system: sys,
@@ -362,11 +409,13 @@ export async function startRepl(opts = {}) {
362
409
  }
363
410
  lastAssistantText = text;
364
411
  queuedStatus({ status: 'done' });
412
+ return text;
365
413
  }
366
414
  catch (err) {
367
415
  const msg = err instanceof Error ? err.message : String(err);
368
416
  queuedAppend({ id: `ro-err-${Date.now()}`, kind: 'error', message: msg });
369
417
  queuedStatus({ status: 'error', errorMessage: msg });
418
+ return '';
370
419
  }
371
420
  }
372
421
  function queuedClear() {
@@ -430,9 +479,16 @@ export async function startRepl(opts = {}) {
430
479
  queuedStatus({ status: 'error', errorMessage: 'no model configured' });
431
480
  return;
432
481
  }
482
+ // Fresh read-guard scope per task: reads from a previous task must not
483
+ // satisfy write guards in this one (same process, shared module state).
484
+ try {
485
+ const { clearReadHistory } = await import('../tools/fs/read-history.js');
486
+ clearReadHistory();
487
+ }
488
+ catch { /* ignore */ }
433
489
  // 2.3 image handling: extract @img refs
434
490
  const { text: cleanText, images } = parseImageInput(text);
435
- const taskText = images.length > 0 ? `${cleanText}\n\n[images: ${images.join(', ')}]` : cleanText;
491
+ let taskText = images.length > 0 ? `${cleanText}\n\n[images: ${images.join(', ')}]` : cleanText;
436
492
  // Only create session for non-trivial tasks (with tools/verify) — plain chat like "hello" is not a persisted session
437
493
  const isSimpleChat = taskText.trim().split(/\s+/).length <= 5 && !taskText.toLowerCase().includes('fix') && !taskText.toLowerCase().includes('add') && !taskText.toLowerCase().includes('create');
438
494
  let sessionId;
@@ -449,6 +505,8 @@ export async function startRepl(opts = {}) {
449
505
  }
450
506
  }
451
507
  queuedStatus({ status: 'running', step: 0, model });
508
+ // Attached files ride along (fresh re-read, capped) on both paths.
509
+ taskText += await buildAttachedBlock();
452
510
  // Simple chat like "hello" must NOT trigger tool calls — direct LLM chat, no tools, no files
453
511
  if (isSimpleChat) {
454
512
  try {
@@ -844,15 +902,57 @@ export async function startRepl(opts = {}) {
844
902
  return;
845
903
  }
846
904
  case 'compact': {
905
+ // Real compaction (not a destructive clear): summarize the persisted
906
+ // session via a read-only pass, then clear the view and retain the
907
+ // summary as the new context anchor.
847
908
  const focus = cmd.focus?.trim();
848
- queuedClear();
849
- queuedAppend({
850
- id: `compact-${Date.now()}`,
851
- kind: 'text',
852
- text: focus ? `Context compacted — transcript cleared (focus: ${focus}). Continuing fresh.` : 'Context compacted — transcript cleared. Continuing fresh.',
853
- role: 'assistant',
854
- });
855
- queuedStatus({ status: 'done' });
909
+ const compactFallback = () => {
910
+ queuedClear();
911
+ queuedAppend({
912
+ id: `compact-${Date.now()}`,
913
+ kind: 'text',
914
+ text: focus ? `Context compacted — transcript cleared (focus: ${focus}). Continuing fresh.` : 'Context compacted — transcript cleared. Continuing fresh.',
915
+ role: 'assistant',
916
+ });
917
+ queuedStatus({ status: 'done' });
918
+ };
919
+ if (!tuiSessionId) {
920
+ compactFallback();
921
+ return;
922
+ }
923
+ try {
924
+ const msgs = await tuiStore.loadMessages(tuiSessionId);
925
+ if (msgs.length === 0) {
926
+ compactFallback();
927
+ return;
928
+ }
929
+ const excerpt = msgs
930
+ .slice(-60)
931
+ .map((m) => {
932
+ const c = m.content;
933
+ const s = typeof c === 'string' ? c : JSON.stringify(c);
934
+ return `[${m.role}] ${s.slice(0, 500)}`;
935
+ })
936
+ .join('\n')
937
+ .slice(0, 12000);
938
+ queuedAppend({ id: `compact-run-${Date.now()}`, kind: 'text', text: 'Compacting context (summarizing session)…', role: 'assistant' });
939
+ const summary = await answerReadOnly(`Summarize this coding session in 10-15 lines for context retention: goal, key decisions, files changed, current state, next steps.${focus ? ` Focus on: ${focus}` : ''}\n\nSession:\n${excerpt}`);
940
+ if (!summary.trim()) {
941
+ compactFallback();
942
+ return;
943
+ }
944
+ queuedClear();
945
+ queuedAppend({
946
+ id: `compact-done-${Date.now()}`,
947
+ kind: 'text',
948
+ text: `Context compacted — retained summary:\n\n${summary.slice(0, 4000)}`,
949
+ role: 'assistant',
950
+ });
951
+ queuedStatus({ status: 'done' });
952
+ }
953
+ catch {
954
+ compactFallback();
955
+ }
856
956
  return;
857
957
  }
858
958
  case 'model': {
@@ -1069,7 +1169,11 @@ export async function startRepl(opts = {}) {
1069
1169
  const cfg = policy.config;
1070
1170
  const p = cmd.policy?.trim();
1071
1171
  if (!p) {
1072
- queuedAppend({ id: `sb-${Date.now()}`, kind: 'text', text: `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}\nusage: /sandbox <dir> (adds an allowed directory)`, role: 'assistant' });
1172
+ queuedAppend({ id: `sb-${Date.now()}`, kind: 'text', text: `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}\nusage: /sandbox <dir> (add) | /sandbox reset (clear)`, role: 'assistant' });
1173
+ }
1174
+ else if (p === 'reset') {
1175
+ cfg.additionalDirs = [];
1176
+ queuedAppend({ id: `sb3-${Date.now()}`, kind: 'text', text: 'sandbox: cleared allowed dirs', role: 'assistant' });
1073
1177
  }
1074
1178
  else {
1075
1179
  cfg.additionalDirs = [...(cfg.additionalDirs ?? []), p];
@@ -1089,11 +1193,23 @@ export async function startRepl(opts = {}) {
1089
1193
  }
1090
1194
  case 'resume': {
1091
1195
  try {
1092
- const { resolveSessionId } = await import('../persistence/session.js');
1196
+ const { resolveSessionId, matchSessionIds } = await import('../persistence/session.js');
1093
1197
  const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
1094
1198
  const full = cmd.id ? await resolveSessionId(tuiStore, cmd.id) : all[0]?.id;
1095
1199
  if (!full) {
1096
- queuedAppend({ id: `resume-err-${Date.now()}`, kind: 'error', message: cmd.id ? `session not found: ${cmd.id}` : 'no previous session in this cwd' });
1200
+ if (cmd.id) {
1201
+ const matches = await matchSessionIds(tuiStore, cmd.id);
1202
+ queuedAppend({
1203
+ id: `resume-err-${Date.now()}`,
1204
+ kind: 'error',
1205
+ message: matches.length > 1
1206
+ ? `ambiguous id "${cmd.id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}`
1207
+ : `session not found: ${cmd.id}`,
1208
+ });
1209
+ }
1210
+ else {
1211
+ queuedAppend({ id: `resume-err-${Date.now()}`, kind: 'error', message: 'no previous session in this cwd' });
1212
+ }
1097
1213
  return;
1098
1214
  }
1099
1215
  const rec = await tuiStore.get(full);
@@ -1137,10 +1253,19 @@ export async function startRepl(opts = {}) {
1137
1253
  }
1138
1254
  case 'fork': {
1139
1255
  try {
1140
- const base = sessionLabel || 'session';
1141
- const rec = await tuiStore.create({ cwd, task: `${base} (fork)${cmd.prompt ? `: ${cmd.prompt}` : ''}`, config: { model, maxSteps: currentMaxSteps } });
1142
- tuiSessionId = rec.id;
1143
- queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${rec.id.slice(0, 8)}`, role: 'assistant' });
1256
+ if (tuiSessionId) {
1257
+ const forked = await tuiStore.fork(tuiSessionId);
1258
+ const msgs = await tuiStore.loadMessages(forked.id);
1259
+ tuiSessionId = forked.id;
1260
+ sessionLabel = forked.task.slice(0, 40);
1261
+ queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)`, role: 'assistant' });
1262
+ }
1263
+ else {
1264
+ const base = sessionLabel || 'session';
1265
+ const rec = await tuiStore.create({ cwd, task: `${base} (fork)${cmd.prompt ? `: ${cmd.prompt}` : ''}`, config: { model, maxSteps: currentMaxSteps } });
1266
+ tuiSessionId = rec.id;
1267
+ queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${rec.id.slice(0, 8)}`, role: 'assistant' });
1268
+ }
1144
1269
  }
1145
1270
  catch (err) {
1146
1271
  queuedAppend({ id: `fork-err-${Date.now()}`, kind: 'error', message: `fork failed: ${err instanceof Error ? err.message : String(err)}` });
@@ -1254,7 +1379,7 @@ export async function startRepl(opts = {}) {
1254
1379
  }
1255
1380
  else {
1256
1381
  const v = r.value;
1257
- const body = String(v.content ?? v.text ?? JSON.stringify(v)).slice(0, 6000);
1382
+ const body = readFileBody(v).slice(0, 6000);
1258
1383
  queuedAppend({ id: `men2-${Date.now()}`, kind: 'text', text: `attached ${cmd.path} (${body.length} chars):\n${body}`, role: 'assistant' });
1259
1384
  }
1260
1385
  }
@@ -1287,7 +1412,7 @@ export async function startRepl(opts = {}) {
1287
1412
  try {
1288
1413
  const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
1289
1414
  if (fr.ok)
1290
- extra = `\n\n--- ${cmd.target} ---\n${String(fr.value.content ?? '').slice(0, 2000)}`;
1415
+ extra = `\n\n--- ${cmd.target} ---\n${readFileBody(fr.value).slice(0, 2000)}`;
1291
1416
  }
1292
1417
  catch { /* ignore */ }
1293
1418
  }
@@ -1445,7 +1570,7 @@ export async function startRepl(opts = {}) {
1445
1570
  try {
1446
1571
  const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
1447
1572
  if (fr.ok)
1448
- ctx = `File ${cmd.target}:\n${String(fr.value.content ?? '').slice(0, 6000)}`;
1573
+ ctx = `File ${cmd.target}:\n${readFileBody(fr.value).slice(0, 6000)}`;
1449
1574
  }
1450
1575
  catch { /* symbol — answer without file context */ }
1451
1576
  await answerReadOnly(`Explain ${cmd.target}: what it does, key logic, and gotchas.`, ctx || undefined);
@@ -1465,7 +1590,10 @@ export async function startRepl(opts = {}) {
1465
1590
  queuedAppend({ id: `fmt2-${Date.now()}`, kind: 'text', text: 'prettier not installed — run `npm i -D prettier` first.', role: 'assistant' });
1466
1591
  }
1467
1592
  else {
1468
- const fv = await execShell(`npx prettier --write ${files.map((f) => `"${f}"`).join(' ')}`);
1593
+ // Strip double quotes from paths (" is illegal in Windows
1594
+ // filenames and a shell escape vector everywhere else).
1595
+ const safeFiles = files.map((f) => f.replace(/"/g, ''));
1596
+ const fv = await execShell(`npx prettier --write ${safeFiles.map((f) => `"${f}"`).join(' ')}`);
1469
1597
  queuedAppend({ id: `fmt3-${Date.now()}`, kind: 'text', text: fv.exitCode === 0 ? `[format] formatted ${files.length} file(s)` : `[format] failed:\n${outCap(fv.stderr || fv.stdout)}`, role: 'assistant' });
1470
1598
  }
1471
1599
  }
@@ -1727,7 +1855,11 @@ export async function startRepl(opts = {}) {
1727
1855
  }
1728
1856
  else {
1729
1857
  cwd = abs;
1730
- queuedAppend({ id: `cd2-${Date.now()}`, kind: 'text', text: `cwd → ${abs} (header refreshes on restart)`, role: 'assistant' });
1858
+ const { resetPersistentCwd } = await import('../tools/shell/shell-exec.js');
1859
+ resetPersistentCwd();
1860
+ const { clearReadHistory } = await import('../tools/fs/read-history.js');
1861
+ clearReadHistory();
1862
+ queuedAppend({ id: `cd2-${Date.now()}`, kind: 'text', text: `cwd → ${abs} (shell cwd + read guards reset; header refreshes on restart)`, role: 'assistant' });
1731
1863
  }
1732
1864
  }
1733
1865
  return;
@@ -1743,7 +1875,7 @@ export async function startRepl(opts = {}) {
1743
1875
  queuedAppend({ id: `att-err-${Date.now()}`, kind: 'error', message: `attach failed: ${r.error.message ?? 'read error'}` });
1744
1876
  }
1745
1877
  else {
1746
- const body = String(r.value.content ?? '');
1878
+ const body = readFileBody(r.value);
1747
1879
  attachedFiles.set(cmd.file, body.length);
1748
1880
  queuedAppend({ id: `att2-${Date.now()}`, kind: 'text', text: `attached ${cmd.file} (${body.length} chars) — in context for future prompts`, role: 'assistant' });
1749
1881
  }
@@ -1886,8 +2018,21 @@ export async function startRepl(opts = {}) {
1886
2018
  const t = setTimeout(() => ctrl.abort(), 15_000);
1887
2019
  const res = await fetch(cmd.url, { signal: ctrl.signal });
1888
2020
  clearTimeout(t);
1889
- const text = (await res.text()).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 6000);
1890
- queuedAppend({ id: `web2-${Date.now()}`, kind: 'text', text: `${cmd.url} [${res.status}]:\n${text || '(no text content)'}`, role: 'assistant' });
2021
+ // SSRF/OOM guard: refuse huge bodies before buffering.
2022
+ const declared = Number(res.headers.get('content-length') ?? 0);
2023
+ if (declared > 2 * 1024 * 1024) {
2024
+ queuedAppend({ id: `web-err-${Date.now()}`, kind: 'error', message: `refusing to fetch ${declared} byte body (2 MiB cap)` });
2025
+ return;
2026
+ }
2027
+ const raw = await res.text();
2028
+ const capped = raw.length > 256 * 1024;
2029
+ const text = raw
2030
+ .slice(0, 256 * 1024)
2031
+ .replace(/<[^>]+>/g, ' ')
2032
+ .replace(/\s+/g, ' ')
2033
+ .trim()
2034
+ .slice(0, 6000);
2035
+ queuedAppend({ id: `web2-${Date.now()}`, kind: 'text', text: `${cmd.url} [${res.status}]${capped ? ' [body capped at 256 KiB]' : ''}:\n${text || '(no text content)'}`, role: 'assistant' });
1891
2036
  }
1892
2037
  catch (err) {
1893
2038
  queuedAppend({ id: `web-err-${Date.now()}`, kind: 'error', message: `fetch failed: ${err instanceof Error ? err.message : String(err)}` });
@@ -1906,7 +2051,7 @@ export async function startRepl(opts = {}) {
1906
2051
  queuedAppend({ id: `read-err-${Date.now()}`, kind: 'error', message: `read failed: ${r.error.message ?? 'error'}` });
1907
2052
  }
1908
2053
  else {
1909
- queuedAppend({ id: `read2-${Date.now()}`, kind: 'text', text: `${cmd.path}:\n${outCap(String(r.value.content ?? ''))}`, role: 'assistant' });
2054
+ queuedAppend({ id: `read2-${Date.now()}`, kind: 'text', text: `${cmd.path}:\n${outCap(readFileBody(r.value))}`, role: 'assistant' });
1910
2055
  }
1911
2056
  }
1912
2057
  catch (err) {
@@ -1952,9 +2097,19 @@ export async function startRepl(opts = {}) {
1952
2097
  queuedAppend({ id: `commit2-${Date.now()}`, kind: 'text', text: 'Nothing staged — stage changes with `git add` first.', role: 'assistant' });
1953
2098
  }
1954
2099
  else {
1955
- const safeMsg = cmd.message.replace(/"/g, "'");
1956
- const v = await execShell(`git commit -m "${safeMsg}"`);
1957
- queuedAppend({ id: `commit3-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `committed ${staged.length} file(s):\n${outCap(v.stdout)}` : `commit failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
2100
+ // Message via temp file (git commit -F): zero shell interpolation.
2101
+ const { writeFile, unlink } = await import('node:fs/promises');
2102
+ const { join } = await import('node:path');
2103
+ const { tmpdir } = await import('node:os');
2104
+ const msgFile = join(tmpdir(), `klyro-commit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`);
2105
+ await writeFile(msgFile, cmd.message, 'utf-8');
2106
+ try {
2107
+ const v = await execShell(`git commit -F "${msgFile}"`);
2108
+ queuedAppend({ id: `commit3-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `committed ${staged.length} file(s):\n${outCap(v.stdout)}` : `commit failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
2109
+ }
2110
+ finally {
2111
+ await unlink(msgFile).catch(() => undefined);
2112
+ }
1958
2113
  }
1959
2114
  }
1960
2115
  catch (err) {
@@ -2024,11 +2179,16 @@ export async function startRepl(opts = {}) {
2024
2179
  }
2025
2180
  else {
2026
2181
  try {
2027
- const { startBackground } = await import('../tools/shell/background.js');
2182
+ const { startBackgroundArgv } = await import('../tools/shell/background.js');
2028
2183
  const { resolve } = await import('node:path');
2029
2184
  const abs = resolve(cwd, file);
2030
- const opener = process.platform === 'win32' ? `start "" "${abs}"` : process.platform === 'darwin' ? `open "${abs}"` : `xdg-open "${abs}"`;
2031
- startBackground(opener, cwd);
2185
+ // argv form (shell:false) — the user-supplied path is never interpolated.
2186
+ const argv = process.platform === 'win32'
2187
+ ? ['cmd', '/c', 'start', '', abs]
2188
+ : process.platform === 'darwin'
2189
+ ? ['open', abs]
2190
+ : ['xdg-open', abs];
2191
+ startBackgroundArgv(argv, cwd);
2032
2192
  queuedAppend({ id: `ed2-${Date.now()}`, kind: 'text', text: `opened ${abs} in background`, role: 'assistant' });
2033
2193
  }
2034
2194
  catch (err) {
@@ -2227,6 +2387,10 @@ export async function startRepl(opts = {}) {
2227
2387
  if (!m) {
2228
2388
  queuedAppend({ id: `env-err-${Date.now()}`, kind: 'error', message: 'usage: /env KEY=value' });
2229
2389
  }
2390
+ else if (['NODE_OPTIONS', 'LD_PRELOAD', 'LD_LIBRARY_PATH'].includes(m[1])) {
2391
+ // Same injection-vector blocklist as shell_exec's filteredEnv.
2392
+ queuedAppend({ id: `env-err2-${Date.now()}`, kind: 'error', message: `refusing to set ${m[1]} (process injection vector)` });
2393
+ }
2230
2394
  else {
2231
2395
  process.env[m[1]] = m[2];
2232
2396
  queuedAppend({ id: `env2-${Date.now()}`, kind: 'text', text: `set ${m[1]} (session-only)`, role: 'assistant' });
package/dist/cli/run.js CHANGED
@@ -12,7 +12,7 @@ import { httpChatAdapter } from '../agent/provider-adapter.js';
12
12
  import { anthropicAdapter } from '../agent/anthropic-adapter.js';
13
13
  import { run } from '../agent/runtime.js';
14
14
  import { builtinRegistry } from '../tools/registry.js';
15
- import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
15
+ import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
16
16
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
17
17
  import { buildLevel6Context } from '../context/level6.js';
18
18
  import { resolveSessionId } from '../persistence/session.js';
@@ -24,8 +24,14 @@ function readEnv(name, fallback) {
24
24
  export async function runOnce(opts) {
25
25
  const output = opts.output ?? 'human';
26
26
  if (opts.dryRun) {
27
- return dryRunReport(opts);
27
+ return await dryRunReport(opts);
28
28
  }
29
+ // Fresh read-guard scope per run (shared module state otherwise leaks across runs).
30
+ try {
31
+ const { clearReadHistory } = await import('../tools/fs/read-history.js');
32
+ clearReadHistory();
33
+ }
34
+ catch { /* ignore */ }
29
35
  let adapter = opts.adapter;
30
36
  if (!adapter) {
31
37
  const provider = opts.provider ?? 'openai';
@@ -52,7 +58,7 @@ export async function runOnce(opts) {
52
58
  }
53
59
  }
54
60
  const registry = builtinRegistry();
55
- const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
61
+ const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
56
62
  const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
57
63
  // Level 9 — session setup (create or resume)
58
64
  const persistEnabled = opts.persist !== false;
@@ -97,12 +103,16 @@ export async function runOnce(opts) {
97
103
  initialTranscript = loadTranscript(opts.resumePath);
98
104
  }
99
105
  const ac = new AbortController();
106
+ const onSigint = () => {
107
+ stderr.write('\nklyro: SIGINT — aborting\n');
108
+ ac.abort();
109
+ };
100
110
  if (opts.abortOnSigint !== false) {
101
- process.on('SIGINT', () => {
102
- stderr.write('\nklyro: SIGINT — aborting\n');
103
- ac.abort();
104
- });
111
+ process.once('SIGINT', onSigint);
105
112
  }
113
+ const doneSigint = () => {
114
+ process.removeListener('SIGINT', onSigint);
115
+ };
106
116
  const verifyOpts = opts.verify === false
107
117
  ? { enabled: false }
108
118
  : {
@@ -112,63 +122,69 @@ export async function runOnce(opts) {
112
122
  timeoutMs: opts.verifyTimeoutMs,
113
123
  requireVerify: opts.requireVerify,
114
124
  };
115
- const result = await run({
116
- task: opts.task,
117
- cwd: opts.cwd,
118
- model: opts.model,
119
- maxSteps: opts.maxSteps,
120
- maxTokens: opts.maxTokens,
121
- temperature: opts.temperature,
122
- signal: ac.signal,
123
- nonInteractive: true,
124
- initialTranscript,
125
- verify: verifyOpts,
126
- persist: store && sessionId ? { store, sessionId } : undefined,
127
- onEvent: (ev) => {
128
- if (output === 'json') {
129
- stdout.write(JSON.stringify(ev) + '\n');
130
- return;
131
- }
132
- if (output === 'silent') {
133
- return;
134
- }
135
- // human mode
136
- if (ev.kind === 'text_delta')
137
- stdout.write(ev.text);
138
- else if (ev.kind === 'tool_call_start') {
139
- stderr.write(`\n[tool] ${ev.name} {\n`);
140
- }
141
- else if (ev.kind === 'tool_call_end') {
142
- stderr.write(`\n}\n`);
143
- }
144
- else if (ev.kind === 'tool_result') {
145
- const summary = typeof ev.output === 'string' ? ev.output.slice(0, 200) : JSON.stringify(ev.output).slice(0, 200);
146
- stderr.write(` -> ${ev.isError ? 'ERR ' : 'ok '}(${ev.latencyMs}ms) ${summary}\n`);
147
- }
148
- else if (ev.kind === 'policy_decision' && ev.action !== 'allow') {
149
- stderr.write(` policy: ${ev.action}${ev.reason ? ` — ${ev.reason}` : ''}\n`);
150
- }
151
- else if (ev.kind === 'step_start') {
152
- stderr.write(`\n[step ${ev.step}]\n`);
153
- }
154
- else if (ev.kind === 'verification_started') {
155
- stderr.write(`\n[verify] running \`${ev.command}\`\n`);
156
- }
157
- else if (ev.kind === 'verification_succeeded') {
158
- stderr.write(`[verify] passed\n`);
159
- }
160
- else if (ev.kind === 'verification_failed') {
161
- stderr.write(`[verify] failed — ${ev.reason.slice(0, 200)}\n`);
162
- }
163
- else if (ev.kind === 'repair_started') {
164
- stderr.write(`[repair] attempt ${ev.attempt}/${ev.maxAttempts}\n`);
165
- }
166
- else if (ev.kind === 'checkpoint_saved') {
167
- if (output === 'human')
168
- stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
169
- }
170
- },
171
- }, { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt });
125
+ let result;
126
+ try {
127
+ result = await run({
128
+ task: opts.task,
129
+ cwd: opts.cwd,
130
+ model: opts.model,
131
+ maxSteps: opts.maxSteps,
132
+ maxTokens: opts.maxTokens,
133
+ temperature: opts.temperature,
134
+ signal: ac.signal,
135
+ nonInteractive: true,
136
+ initialTranscript,
137
+ verify: verifyOpts,
138
+ persist: store && sessionId ? { store, sessionId } : undefined,
139
+ onEvent: (ev) => {
140
+ if (output === 'json') {
141
+ stdout.write(JSON.stringify(ev) + '\n');
142
+ return;
143
+ }
144
+ if (output === 'silent') {
145
+ return;
146
+ }
147
+ // human mode
148
+ if (ev.kind === 'text_delta')
149
+ stdout.write(ev.text);
150
+ else if (ev.kind === 'tool_call_start') {
151
+ stderr.write(`\n[tool] ${ev.name} {\n`);
152
+ }
153
+ else if (ev.kind === 'tool_call_end') {
154
+ stderr.write(`\n}\n`);
155
+ }
156
+ else if (ev.kind === 'tool_result') {
157
+ const summary = typeof ev.output === 'string' ? ev.output.slice(0, 200) : JSON.stringify(ev.output).slice(0, 200);
158
+ stderr.write(` -> ${ev.isError ? 'ERR ' : 'ok '}(${ev.latencyMs}ms) ${summary}\n`);
159
+ }
160
+ else if (ev.kind === 'policy_decision' && ev.action !== 'allow') {
161
+ stderr.write(` policy: ${ev.action}${ev.reason ? ` — ${ev.reason}` : ''}\n`);
162
+ }
163
+ else if (ev.kind === 'step_start') {
164
+ stderr.write(`\n[step ${ev.step}]\n`);
165
+ }
166
+ else if (ev.kind === 'verification_started') {
167
+ stderr.write(`\n[verify] running \`${ev.command}\`\n`);
168
+ }
169
+ else if (ev.kind === 'verification_succeeded') {
170
+ stderr.write(`[verify] passed\n`);
171
+ }
172
+ else if (ev.kind === 'verification_failed') {
173
+ stderr.write(`[verify] failed — ${ev.reason.slice(0, 200)}\n`);
174
+ }
175
+ else if (ev.kind === 'repair_started') {
176
+ stderr.write(`[repair] attempt ${ev.attempt}/${ev.maxAttempts}\n`);
177
+ }
178
+ else if (ev.kind === 'checkpoint_saved') {
179
+ if (output === 'human')
180
+ stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
181
+ }
182
+ },
183
+ }, { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt });
184
+ }
185
+ finally {
186
+ doneSigint();
187
+ }
172
188
  if (store && sessionId) {
173
189
  // Finalize session status
174
190
  const statusMap = {
@@ -189,7 +205,7 @@ export async function runOnce(opts) {
189
205
  stderr.write('\n');
190
206
  }
191
207
  }
192
- if (output !== 'json')
208
+ if (output === 'human')
193
209
  stdout.write('\n');
194
210
  if (result.status === 'max_steps') {
195
211
  if (output === 'json')
@@ -221,8 +237,8 @@ export async function runOnce(opts) {
221
237
  stderr.write('klyro: --require-verify: verification required but not passed\n');
222
238
  return 8;
223
239
  }
224
- if (opts.requireVerify && !result.verification) {
225
- // hasEdits but no verification command found
240
+ if (opts.requireVerify && !result.verification && result.hasEdits) {
241
+ // Edits were made but no verification command found
226
242
  if (output === 'json')
227
243
  stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_missing' }) + '\n');
228
244
  else
@@ -233,8 +249,11 @@ export async function runOnce(opts) {
233
249
  stdout.write(JSON.stringify({ kind: 'final', status: 'ok', text: result.finalText }) + '\n');
234
250
  return 0;
235
251
  }
236
- function dryRunReport(opts) {
237
- const systemPrompt = (opts.systemPrompt ?? defaultRunSystemPrompt)({ cwd: opts.cwd });
252
+ async function dryRunReport(opts) {
253
+ // Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
254
+ // otherwise dry-run shows a different prompt than production runs use.
255
+ const systemPromptFn = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
256
+ const systemPrompt = systemPromptFn({ cwd: opts.cwd });
238
257
  const registry = builtinRegistry();
239
258
  const rules = builtinRules();
240
259
  const report = {
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * 4.4 — KLYRO.md loader: ~/.klyro/KLYRO.md → root → KLYRO.local.md → subdir files lazily
3
+ *
4
+ * Safety: @import targets are contained to the project cwd (escapes are
5
+ * skipped, not read — a malicious KLYRO.md must not pull /etc/passwd or
6
+ * secret files into context), each file is capped, and total output is
7
+ * capped so a giant monorepo doc can't blow the context budget.
3
8
  */
4
9
  export declare function loadKlyroMd(cwd: string): Promise<string>;
5
10
  export declare function handleInit(cwd: string): Promise<string>;