klyro 0.1.63 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +73 -16
  3. package/dist/agent/capabilities.d.ts +145 -0
  4. package/dist/agent/capabilities.js +191 -0
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +232 -0
  8. package/dist/agent/orchestrator.js +589 -0
  9. package/dist/agent/provider-adapter.d.ts +17 -0
  10. package/dist/agent/provider-adapter.js +35 -3
  11. package/dist/agent/registry.d.ts +1 -0
  12. package/dist/agent/registry.js +1 -0
  13. package/dist/agent/retry.d.ts +13 -2
  14. package/dist/agent/retry.js +21 -3
  15. package/dist/agent/runtime.d.ts +70 -8
  16. package/dist/agent/runtime.js +205 -34
  17. package/dist/agent/scoped-registry.d.ts +22 -0
  18. package/dist/agent/scoped-registry.js +42 -0
  19. package/dist/agent/task-manager.d.ts +115 -0
  20. package/dist/agent/task-manager.js +250 -0
  21. package/dist/agent/worker-spawner.d.ts +17 -12
  22. package/dist/agent/worker-spawner.js +26 -20
  23. package/dist/agent/worktree-manager.d.ts +74 -0
  24. package/dist/agent/worktree-manager.js +189 -0
  25. package/dist/checkpoints/store.js +30 -5
  26. package/dist/cli/auth.js +16 -1
  27. package/dist/cli/config.d.ts +9 -3
  28. package/dist/cli/config.js +64 -3
  29. package/dist/cli/dotenv.d.ts +3 -0
  30. package/dist/cli/dotenv.js +57 -0
  31. package/dist/cli/eval.d.ts +6 -1
  32. package/dist/cli/eval.js +9 -0
  33. package/dist/cli/repl.js +183 -17
  34. package/dist/cli/run.d.ts +10 -11
  35. package/dist/cli/run.js +176 -14
  36. package/dist/cli/update.d.ts +5 -0
  37. package/dist/cli/update.js +62 -10
  38. package/dist/context/import-graph.d.ts +2 -0
  39. package/dist/context/import-graph.js +31 -3
  40. package/dist/context/klyro-md.d.ts +6 -0
  41. package/dist/context/klyro-md.js +25 -16
  42. package/dist/context/memory.d.ts +8 -0
  43. package/dist/context/memory.js +50 -2
  44. package/dist/context/project-map.d.ts +6 -0
  45. package/dist/context/project-map.js +50 -2
  46. package/dist/context/repo-map.d.ts +2 -0
  47. package/dist/context/repo-map.js +31 -1
  48. package/dist/context/trust.d.ts +42 -0
  49. package/dist/context/trust.js +111 -0
  50. package/dist/events/catalog.d.ts +99 -0
  51. package/dist/index.js +93 -4
  52. package/dist/mcp/client.d.ts +55 -0
  53. package/dist/mcp/client.js +294 -0
  54. package/dist/mcp/config.d.ts +40 -0
  55. package/dist/mcp/config.js +99 -0
  56. package/dist/mcp/policy.d.ts +13 -0
  57. package/dist/mcp/policy.js +12 -0
  58. package/dist/mcp/registry.d.ts +72 -0
  59. package/dist/mcp/registry.js +244 -0
  60. package/dist/mcp/schema.d.ts +18 -0
  61. package/dist/mcp/schema.js +57 -0
  62. package/dist/mcp/trust.d.ts +20 -0
  63. package/dist/mcp/trust.js +74 -0
  64. package/dist/persistence/audit.d.ts +28 -0
  65. package/dist/persistence/audit.js +101 -1
  66. package/dist/persistence/store.d.ts +26 -2
  67. package/dist/persistence/store.js +140 -13
  68. package/dist/policy/approval.d.ts +14 -0
  69. package/dist/policy/approval.js +44 -2
  70. package/dist/policy/engine.d.ts +1 -0
  71. package/dist/policy/engine.js +91 -10
  72. package/dist/policy/secret-redactor.js +4 -0
  73. package/dist/providers/model-info.d.ts +17 -0
  74. package/dist/providers/model-info.js +35 -2
  75. package/dist/repl.d.ts +6 -0
  76. package/dist/repl.js +12 -7
  77. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  78. package/dist/tools/agent/spawn-agent.js +50 -0
  79. package/dist/tools/agent/task-apply.d.ts +4 -0
  80. package/dist/tools/agent/task-apply.js +44 -0
  81. package/dist/tools/agent/task-get.d.ts +8 -0
  82. package/dist/tools/agent/task-get.js +40 -0
  83. package/dist/tools/agent/task-list.d.ts +4 -0
  84. package/dist/tools/agent/task-list.js +41 -0
  85. package/dist/tools/agent/task-stop.d.ts +6 -0
  86. package/dist/tools/agent/task-stop.js +39 -0
  87. package/dist/tools/agent/task-wait.d.ts +17 -0
  88. package/dist/tools/agent/task-wait.js +79 -0
  89. package/dist/tools/fs/apply-patch.js +71 -0
  90. package/dist/tools/fs/edit-file.js +65 -0
  91. package/dist/tools/fs/multi-edit.d.ts +4 -0
  92. package/dist/tools/fs/multi-edit.js +66 -0
  93. package/dist/tools/fs/write-file.js +67 -0
  94. package/dist/tools/plan/todo-write.d.ts +1 -1
  95. package/dist/tools/registry.js +12 -0
  96. package/dist/tools/shell/background.js +6 -3
  97. package/dist/tools/shell/sandbox.d.ts +51 -0
  98. package/dist/tools/shell/sandbox.js +143 -0
  99. package/dist/tools/shell/shell-exec.d.ts +1 -0
  100. package/dist/tools/shell/shell-exec.js +83 -11
  101. package/dist/tools/shell/worker-entry.d.ts +12 -0
  102. package/dist/tools/shell/worker-entry.js +43 -0
  103. package/dist/tools/types.d.ts +18 -0
  104. package/dist/tools/verify/run-verify.js +3 -1
  105. package/dist/trace/writer.d.ts +13 -0
  106. package/dist/trace/writer.js +55 -4
  107. package/dist/tui/app.js +1 -1
  108. package/dist/tui/approval.js +20 -21
  109. package/dist/util.d.ts +1 -0
  110. package/dist/util.js +1 -0
  111. package/dist/verification/baseline.js +17 -3
  112. package/dist/verification/classify.js +23 -12
  113. package/dist/verification/engine.js +3 -1
  114. package/dist/verification/registry.d.ts +2 -0
  115. package/dist/verification/registry.js +33 -0
  116. package/dist/verification/scoped.js +28 -6
  117. package/package.json +1 -1
@@ -10,8 +10,10 @@
10
10
  * SessionStore interface.
11
11
  */
12
12
  import * as fs from 'node:fs/promises';
13
+ import * as fsSync from 'node:fs';
13
14
  import * as path from 'node:path';
14
15
  import { randomUUID } from 'node:crypto';
16
+ import { redact } from '../policy/secret-redactor.js';
15
17
  export class SessionStore {
16
18
  dir;
17
19
  indexPath;
@@ -75,6 +77,28 @@ export class SessionStore {
75
77
  return Math.abs(h).toString(36);
76
78
  }
77
79
  perProjectIndexPath(cwd) { return path.join(this.dir, `index-${this.projectHash(cwd)}.json`); }
80
+ /** Atomic per-project index write (tmp + fsync + rename). */
81
+ async writePerProjectIndex(pp, pIdx) {
82
+ const tmp = `${pp}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
83
+ await fs.writeFile(tmp, JSON.stringify(pIdx, null, 2), 'utf-8');
84
+ try {
85
+ const fh = await fs.open(tmp, 'r+');
86
+ try {
87
+ await fh.sync();
88
+ }
89
+ finally {
90
+ await fh.close();
91
+ }
92
+ }
93
+ catch { /* ignore on Windows */ }
94
+ try {
95
+ await fs.rename(tmp, pp);
96
+ }
97
+ catch {
98
+ await fs.unlink(tmp).catch(() => undefined);
99
+ throw new Error('Failed to write per-project sessions index');
100
+ }
101
+ }
78
102
  titleFor(task) {
79
103
  // heuristic title via first 6 words, or model.small would be used if available
80
104
  const w = task.trim().split(/\s+/).slice(0, 6).join(' ');
@@ -84,24 +108,39 @@ export class SessionStore {
84
108
  // Locked: the index read-modify-write below races under concurrent creates.
85
109
  return this.withLock('index', async () => {
86
110
  await this.ensureDir();
87
- const id = randomUUID();
111
+ // Redact at the persist boundary — tasks routinely paste keys/tokens.
112
+ const task = redact(opts.task);
113
+ let id = randomUUID();
114
+ // Cross-process session lock: O_EXCL so a concurrent creator (another
115
+ // process resuming/creating the same id path) can detect contention.
116
+ // On collision fall back to a unique id suffix — never crash.
117
+ try {
118
+ await fs.writeFile(path.join(this.dir, `${id}.lock`), String(process.pid), { flag: 'wx' });
119
+ }
120
+ catch {
121
+ id = `${id}-${process.pid}-${Date.now().toString(36)}`;
122
+ try {
123
+ await fs.writeFile(path.join(this.dir, `${id}.lock`), String(process.pid), { flag: 'wx' });
124
+ }
125
+ catch { /* best-effort only */ }
126
+ }
88
127
  const now = Date.now();
89
128
  const record = {
90
129
  id,
91
130
  cwd: opts.cwd,
92
- task: opts.task,
131
+ task,
93
132
  status: 'open',
94
133
  createdAt: now,
95
134
  updatedAt: now,
96
135
  config: opts.config,
97
136
  };
98
- record.title = this.titleFor(opts.task);
137
+ record.title = this.titleFor(task);
99
138
  await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify({ record, messages: [], observations: [] }, null, 2));
100
139
  await this.appendJsonl(id, { type: 'session.create', record, ts: now });
101
140
  const idx = await this.readIndex();
102
141
  idx[id] = record;
103
142
  await this.writeIndex(idx);
104
- // per-project index
143
+ // per-project index (atomic)
105
144
  try {
106
145
  const pp = this.perProjectIndexPath(opts.cwd);
107
146
  let pIdx = {};
@@ -110,7 +149,7 @@ export class SessionStore {
110
149
  }
111
150
  catch { }
112
151
  pIdx[id] = record;
113
- await fs.writeFile(pp, JSON.stringify(pIdx, null, 2), 'utf-8');
152
+ await this.writePerProjectIndex(pp, pIdx);
114
153
  }
115
154
  catch { }
116
155
  return record;
@@ -136,13 +175,13 @@ export class SessionStore {
136
175
  await this.appendJsonl(forked.id, { type: 'session.fork', from: id, ts: Date.now() });
137
176
  return forked;
138
177
  }
139
- /** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
178
+ /** Delete a session and all its artifacts (record, transcript, jsonl, lock, indexes). */
140
179
  async delete(id) {
141
180
  return this.withLock('index', async () => {
142
181
  const rec = await this.get(id);
143
182
  if (!rec)
144
183
  return false;
145
- for (const f of [`${id}.json`, `${id}.jsonl`]) {
184
+ for (const f of [`${id}.json`, `${id}.jsonl`, `${id}.lock`]) {
146
185
  try {
147
186
  await fs.unlink(path.join(this.dir, f));
148
187
  }
@@ -156,7 +195,7 @@ export class SessionStore {
156
195
  const raw = await fs.readFile(pp, 'utf-8');
157
196
  const pIdx = JSON.parse(raw);
158
197
  delete pIdx[id];
159
- await fs.writeFile(pp, JSON.stringify(pIdx, null, 2), 'utf-8');
198
+ await this.writePerProjectIndex(pp, pIdx);
160
199
  }
161
200
  catch { /* ignore */ }
162
201
  return true;
@@ -241,14 +280,19 @@ export class SessionStore {
241
280
  async appendMessage(id, message) {
242
281
  return this.withLock(id, async () => {
243
282
  const data = await this.readSession(id);
244
- data.messages.push(message);
283
+ data.messages.push({ ...message, content: redactStoredContent(message.content) });
245
284
  await this.writeSession(id, data);
246
285
  });
247
286
  }
248
287
  async appendObservation(id, obs) {
249
288
  return this.withLock(id, async () => {
250
289
  const data = await this.readSession(id);
251
- data.observations.push(obs);
290
+ // S4-at-rest: redact observation input/output at the persist boundary.
291
+ data.observations.push({
292
+ ...obs,
293
+ input: redactStoredContent(obs.input),
294
+ output: redactStoredContent(obs.output),
295
+ });
252
296
  await this.writeSession(id, data);
253
297
  });
254
298
  }
@@ -256,9 +300,16 @@ export class SessionStore {
256
300
  return this.withLock(id, async () => {
257
301
  const data = await this.readSession(id);
258
302
  data.record.status = status;
303
+ // S4-at-rest: redact finalText before persisting.
259
304
  if (finalText !== undefined)
260
- data.record.finalText = finalText;
305
+ data.record.finalText = redact(finalText);
261
306
  await this.writeSession(id, data);
307
+ // Terminal states release the cross-process lock (no destructor exists
308
+ // to do it — the session is done, so the lock is stale by definition).
309
+ if (status === 'complete' || status === 'aborted' || status === 'max_steps' ||
310
+ status === 'verify_failed' || status === 'stuck') {
311
+ await releaseSessionLock(this.dir, id);
312
+ }
262
313
  });
263
314
  }
264
315
  async loadMessages(id) {
@@ -278,9 +329,85 @@ export class SessionStore {
278
329
  const idx = await this.readIndex();
279
330
  return idx[id] ?? null;
280
331
  }
281
- /** Atomic append — survives crashes; suitable for audit log. */
332
+ /** Atomic append + fsync — survives crashes; suitable for audit log. */
282
333
  static async appendJsonl(filePath, entry) {
283
334
  await fs.mkdir(path.dirname(filePath), { recursive: true });
284
- await fs.appendFile(filePath, JSON.stringify(entry) + '\n', 'utf-8');
335
+ const fh = await fs.open(filePath, 'a');
336
+ try {
337
+ await fh.write(JSON.stringify(entry) + '\n');
338
+ await fh.sync();
339
+ }
340
+ finally {
341
+ await fh.close();
342
+ }
343
+ }
344
+ }
345
+ /**
346
+ * Redact text blocks of a stored message at the persist boundary.
347
+ * Handles string content, `{ text }` blocks, arrays of blocks, and plain
348
+ * objects (observation input/output) via a deep walk over string leaves.
349
+ * Only secret-shaped strings (key/token/password patterns, [:=-] shapes)
350
+ * are touched — normal prose passes through unchanged.
351
+ */
352
+ export function redactStoredContent(content) {
353
+ if (typeof content === 'string')
354
+ return redact(content);
355
+ if (Array.isArray(content)) {
356
+ return content.map((b) => {
357
+ if (b && typeof b === 'object' && typeof b.text === 'string') {
358
+ return { ...b, text: redact(b.text) };
359
+ }
360
+ if (typeof b === 'string')
361
+ return redact(b);
362
+ if (b && typeof b === 'object')
363
+ return redactStoredContent(b);
364
+ return b;
365
+ });
366
+ }
367
+ if (content && typeof content === 'object') {
368
+ const rec = content;
369
+ if (typeof rec.text === 'string') {
370
+ return { ...rec, text: redact(rec.text) };
371
+ }
372
+ const out = {};
373
+ for (const [k, v] of Object.entries(rec)) {
374
+ out[k] = typeof v === 'string' ? redact(v) : (v && typeof v === 'object' ? redactStoredContent(v) : v);
375
+ }
376
+ return out;
377
+ }
378
+ return content;
379
+ }
380
+ /**
381
+ * Cross-process session lock probe. The lock file `<sessionsDir>/<id>.lock`
382
+ * contains the holder's PID. `alive` is resolved via `process.kill(pid, 0)`:
383
+ * EPERM means alive-but-unpermitted (→ true); ESRCH / other errors → false.
384
+ */
385
+ export function readSessionLock(sessionsDir, id) {
386
+ let raw;
387
+ try {
388
+ raw = fsSync.readFileSync(path.join(sessionsDir, `${id}.lock`), 'utf-8');
389
+ }
390
+ catch {
391
+ return { held: false };
392
+ }
393
+ const pid = Number(raw.trim());
394
+ if (!Number.isInteger(pid) || pid <= 0)
395
+ return { held: true };
396
+ let alive;
397
+ try {
398
+ process.kill(pid, 0);
399
+ alive = true;
400
+ }
401
+ catch (err) {
402
+ const code = err?.code;
403
+ alive = code === 'EPERM';
404
+ }
405
+ return { held: true, pid, alive };
406
+ }
407
+ /** Best-effort release of the cross-process session lock. Never throws. */
408
+ export async function releaseSessionLock(sessionsDir, id) {
409
+ try {
410
+ await fs.unlink(path.join(sessionsDir, `${id}.lock`));
285
411
  }
412
+ catch { /* ignore */ }
286
413
  }
@@ -25,6 +25,20 @@ export interface ApprovalRequest {
25
25
  export interface ApprovalPrompt {
26
26
  ask(req: ApprovalRequest): Promise<ApprovalChoice>;
27
27
  }
28
+ /**
29
+ * Sanitize untrusted tool text before showing it in an approval prompt.
30
+ * Strips ANSI escapes (CSI `ESC[...letter` and OSC `ESC]...BEL`), control
31
+ * chars except `\n`/`\t`, and truncates to 500 chars with a hidden-count
32
+ * marker so prompt-injection / terminal-escape payloads can't ride along.
33
+ */
34
+ export declare function sanitizeForPrompt(s: string): string;
35
+ /**
36
+ * Single-key approval mapping shared by the TUI modal (and documented here
37
+ * so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
38
+ * persist to settings. Case MUST be checked before lowercasing — the old TUI
39
+ * code lowercased first, so `A` silently became session-only `always`.
40
+ */
41
+ export declare function approvalChoiceForKey(inputStr: string): ApprovalChoice | 'expand' | 'explain' | null;
28
42
  /** Default prompt backed by readline on stdin/stdout. */
29
43
  export declare class StdinApprovalPrompt implements ApprovalPrompt {
30
44
  ask(req: ApprovalRequest): Promise<ApprovalChoice>;
@@ -8,6 +8,45 @@
8
8
  import * as readline from 'node:readline/promises';
9
9
  import { stdin as input, stdout as output } from 'node:process';
10
10
  import { patternForCall } from './patterns.js';
11
+ /**
12
+ * Sanitize untrusted tool text before showing it in an approval prompt.
13
+ * Strips ANSI escapes (CSI `ESC[...letter` and OSC `ESC]...BEL`), control
14
+ * chars except `\n`/`\t`, and truncates to 500 chars with a hidden-count
15
+ * marker so prompt-injection / terminal-escape payloads can't ride along.
16
+ */
17
+ export function sanitizeForPrompt(s) {
18
+ const noAnsi = s
19
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
20
+ .replace(/\x1b\][^\x07]*\x07/g, '');
21
+ const clean = noAnsi.replace(/[\x00-\x08\x0b-\x0d\x0e-\x1f\x7f]/g, '');
22
+ const LIMIT = 500;
23
+ if (clean.length > LIMIT) {
24
+ return clean.slice(0, LIMIT) + `…[+${clean.length - LIMIT} hidden]`;
25
+ }
26
+ return clean;
27
+ }
28
+ /**
29
+ * Single-key approval mapping shared by the TUI modal (and documented here
30
+ * so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
31
+ * persist to settings. Case MUST be checked before lowercasing — the old TUI
32
+ * code lowercased first, so `A` silently became session-only `always`.
33
+ */
34
+ export function approvalChoiceForKey(inputStr) {
35
+ if (inputStr === 'A')
36
+ return 'always-persist';
37
+ const c = inputStr.toLowerCase();
38
+ if (c === 'y')
39
+ return 'allow';
40
+ if (c === 'a')
41
+ return 'always';
42
+ if (c === 'd' || c === 'n' || c === 'e')
43
+ return 'deny';
44
+ if (c === 'f')
45
+ return 'expand';
46
+ if (c === '?')
47
+ return 'explain';
48
+ return null;
49
+ }
11
50
  /** Default prompt backed by readline on stdin/stdout. */
12
51
  export class StdinApprovalPrompt {
13
52
  async ask(req) {
@@ -15,10 +54,13 @@ export class StdinApprovalPrompt {
15
54
  return 'deny';
16
55
  const rl = readline.createInterface({ input, output });
17
56
  try {
18
- process.stdout.write(`\n[approval needed] ${req.toolName}: ${req.summary}\n reason: ${req.reason}\n allow? [y/n/a(llow)] `);
19
- const ans = (await rl.question('')).trim().toLowerCase();
57
+ process.stdout.write(`\n[approval needed] ${req.toolName}: ${sanitizeForPrompt(req.summary)}\n reason: ${sanitizeForPrompt(req.reason)}\n allow? [y/n/a/A(llow always → settings)] (y=once, n=deny, a=session, A=persist) `);
58
+ const raw = (await rl.question('')).trim();
59
+ const ans = raw.toLowerCase();
20
60
  if (ans === 'y' || ans === 'yes')
21
61
  return 'allow';
62
+ if (raw === 'A')
63
+ return 'always-persist';
22
64
  if (ans === 'a' || ans === 'allow')
23
65
  return 'always';
24
66
  return 'deny';
@@ -83,6 +83,7 @@ export declare class PolicyEngine {
83
83
  }
84
84
  /** Builtin set of rules. Order matters: first match wins. */
85
85
  export declare function builtinRules(): PolicyRule[];
86
+ export declare function matchesGlobRule(call: ToolCallLike, rule: string): boolean;
86
87
  /** Hard-deny for obviously destructive shell patterns. */
87
88
  export declare const shellDenyRule: PolicyRule;
88
89
  /** Allowlist for shell — exact or prefix. Anything not in the list asks. */
@@ -80,17 +80,33 @@ export class PolicyEngine {
80
80
  const globDecision = this.evaluateGlobRules(call);
81
81
  if (globDecision)
82
82
  return globDecision;
83
- // 3.4 — .env guard: deny writes to .env files unless explicitly allowed
84
- if ((call.name === 'write_file' || call.name === 'edit_file') && typeof call.input.path === 'string') {
85
- const p = String(call.input.path);
86
- if (/(^|\/)\.env(\.|$)/.test(p) || p.endsWith('.env')) {
87
- // Check if explicitly allowed via allow list
88
- const allowed = (ctx.config.allow ?? []).some((r) => r.includes('.env'));
89
- if (!allowed) {
83
+ // Centralized .env guard: any tool with a string `path` input pointing
84
+ // at an env file is denied unless an allow rule explicitly names that
85
+ // tool+path (exact/glob match — substring `.includes('.env')` is NOT enough).
86
+ const maybePath = typeof call.input.path === 'string' ? String(call.input.path) : null;
87
+ if (maybePath !== null && isEnvPath(maybePath)) {
88
+ const allowed = isExplicitlyAllowedForPath(call.name, maybePath, ctx.config.allow);
89
+ if (!allowed) {
90
+ return { action: 'deny', reason: 'write to .env denied by policy — add to allow list or use --yolo' };
91
+ }
92
+ }
93
+ // apply_patch carries paths inside the patch text, not `input.path`.
94
+ if (call.name === 'apply_patch' && typeof call.input.patch === 'string') {
95
+ const targets = extractPatchPaths(String(call.input.patch)).filter(isEnvPath);
96
+ if (targets.length > 0) {
97
+ const allAllowed = targets.every((t) => isExplicitlyAllowedForPath(call.name, t, ctx.config.allow));
98
+ if (!allAllowed) {
90
99
  return { action: 'deny', reason: 'write to .env denied by policy — add to allow list or use --yolo' };
91
100
  }
92
101
  }
93
102
  }
103
+ // Shell redirection into .env (e.g. `echo x > .env`, `cmd >> .env.local`).
104
+ if (call.name === 'shell_exec' && typeof call.input.command === 'string') {
105
+ const cmd = String(call.input.command);
106
+ if (/>+\s*['"]?[^'"\s]*\.env/i.test(cmd)) {
107
+ return { action: 'deny', reason: 'write to .env via shell redirection denied' };
108
+ }
109
+ }
94
110
  for (const rule of this.rules) {
95
111
  const d = rule.evaluate(call, ctx);
96
112
  if (d)
@@ -157,9 +173,35 @@ export function builtinRules() {
157
173
  function asString(v) {
158
174
  return typeof v === 'string' ? v : '';
159
175
  }
160
- function matchesGlobRule(call, rule) {
161
- // Rule grammar: tool or tool(glob). e.g. "write_file", "write_file(.env)", "shell_exec(npm *)"
162
- const m = /^([a-z_]+)(?:\((.*)\))?$/.exec(rule.trim());
176
+ function isEnvPath(p) {
177
+ const lower = p.toLowerCase();
178
+ // Sample/template carve-out: `.env.example`-style files are documentation,
179
+ // not secrets — reads AND writes are allowed.
180
+ if (/[.](example|sample|template|dist)$/.test(lower))
181
+ return false;
182
+ return /(^|\/)\.env(\.|$)/.test(lower) || lower.endsWith('.env');
183
+ }
184
+ function extractPatchPaths(patch) {
185
+ const out = [];
186
+ for (const line of patch.split('\n')) {
187
+ if (line.startsWith('*** Update File:'))
188
+ out.push(line.replace('*** Update File:', '').trim());
189
+ else if (line.startsWith('*** Add File:'))
190
+ out.push(line.replace('*** Add File:', '').trim());
191
+ }
192
+ return out.filter(Boolean);
193
+ }
194
+ /** True when an allow rule explicitly names this tool+path (exact/glob match, not substring). */
195
+ function isExplicitlyAllowedForPath(callName, envPath, allow) {
196
+ if (!allow || allow.length === 0)
197
+ return false;
198
+ const synthetic = { name: callName, input: { path: envPath } };
199
+ return allow.some((r) => matchesGlobRule(synthetic, r));
200
+ }
201
+ export function matchesGlobRule(call, rule) {
202
+ // Rule grammar: tool or tool(glob). e.g. "write_file", "write_file(.env)", "shell_exec(npm *)".
203
+ // Digits are allowed so generated names (e.g. mcp__server__tool2) can be targeted.
204
+ const m = /^([a-z0-9_]+)(?:\((.*)\))?$/.exec(rule.trim());
163
205
  if (!m)
164
206
  return false;
165
207
  const tool = m[1];
@@ -203,6 +245,45 @@ export const shellDenyRule = {
203
245
  if (/\b(curl|wget|fetch|iwr|Invoke-WebRequest)\b.*\|\s*(sh|bash|zsh|cmd|powershell|pwsh|node|node\s+-e)\b/i.test(cmd)) {
204
246
  return { action: 'deny', reason: 'pipe-to-shell: remote download piped into an interpreter' };
205
247
  }
248
+ // Exfiltration denylist (mirrors shell_exec DANGEROUS_PATTERNS).
249
+ if (/\bcurl\b.*(?:\s-d\b|\s--data(?:-binary|-urlencode)?\b|\s--upload-file\b)/i.test(cmd)) {
250
+ return { action: 'deny', reason: 'exfiltration: curl with --data/-d/--upload-file denied' };
251
+ }
252
+ if (/\bwget\b.*--post-(data|file)\b/i.test(cmd)) {
253
+ return { action: 'deny', reason: 'exfiltration: wget --post-data/--post-file denied' };
254
+ }
255
+ if (/\b(powershell|pwsh)\b.*-e(nc|ncodedcommand)\b/i.test(cmd)) {
256
+ return { action: 'deny', reason: 'exfiltration: powershell -enc denied' };
257
+ }
258
+ if (/\bInvoke-Expression\b/i.test(cmd) || /\biex\s*\(/i.test(cmd)) {
259
+ return { action: 'deny', reason: 'exfiltration: Invoke-Expression/iex denied' };
260
+ }
261
+ if (/\bcertutil\b.*-urlcache\b/i.test(cmd)) {
262
+ return { action: 'deny', reason: 'exfiltration: certutil -urlcache denied' };
263
+ }
264
+ if (/\bbitsadmin\b.*\/transfer\b/i.test(cmd)) {
265
+ return { action: 'deny', reason: 'exfiltration: bitsadmin /transfer denied' };
266
+ }
267
+ // .env writes via tee / PowerShell (mirrors shell_exec DANGEROUS_PATTERNS).
268
+ if (/\|\s*tee\b[^\n]*\.env/i.test(cmd)) {
269
+ return { action: 'deny', reason: 'write to .env via tee denied' };
270
+ }
271
+ if (/\b(Set-Content|Out-File)\b[^\n]*\.env/i.test(cmd)) {
272
+ return { action: 'deny', reason: 'write to .env via Set-Content/Out-File denied' };
273
+ }
274
+ // Upload-form exfiltration (mirrors shell_exec DANGEROUS_PATTERNS).
275
+ if (/\bcurl\b.*(?:\s-F\b|\s--form\b)/i.test(cmd)) {
276
+ return { action: 'deny', reason: 'exfiltration: curl -F/--form denied' };
277
+ }
278
+ if (/\bwget\b.*(?:\s--method=POST\b|\s--body-data\b)/i.test(cmd)) {
279
+ return { action: 'deny', reason: 'exfiltration: wget --method=POST/--body-data denied' };
280
+ }
281
+ if (/\bInvoke-RestMethod\b/i.test(cmd)) {
282
+ return { action: 'deny', reason: 'exfiltration: Invoke-RestMethod denied' };
283
+ }
284
+ if (/\bStart-BitsTransfer\b/i.test(cmd)) {
285
+ return { action: 'deny', reason: 'exfiltration: Start-BitsTransfer denied' };
286
+ }
206
287
  return null;
207
288
  },
208
289
  };
@@ -24,6 +24,10 @@ const PATTERNS = [
24
24
  { name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}['"]?/gi },
25
25
  { name: 'password', re: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi },
26
26
  { name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{16,}['"]?/gi },
27
+ { name: 'discord-token', re: /mfa\.[\w-]{84}|[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g },
28
+ { name: 'npm-token', re: /\bnpm_[A-Za-z0-9]{24,}/g },
29
+ { name: 'sendgrid-key', re: /\bSG\.[\w-]{22}\.[\w-]{43}/g },
30
+ { name: 'pypi-token', re: /\bpypi-[A-Za-z0-9_-]{30,}/g },
27
31
  ];
28
32
  const REPLACEMENT = '[REDACTED]';
29
33
  /** Redact a single string (or Buffer). */
@@ -15,4 +15,21 @@ export interface ModelInfo {
15
15
  }
16
16
  export declare const MODEL_REGISTRY: Record<string, ModelInfo>;
17
17
  export declare function getModelInfo(id: string): ModelInfo;
18
+ /**
19
+ * Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
20
+ * prices live in exactly one place. Local/self-hosted entries are $0.
21
+ */
22
+ export declare const MODEL_RATES_PER_1K: Record<string, {
23
+ input: number;
24
+ output: number;
25
+ }>;
26
+ /**
27
+ * Resolve per-1K rates for any model id. Exact registry hits win, then the
28
+ * local-model check ($0), then hosted-family fallbacks. Unknown models are
29
+ * $0 — never invent nonzero prices for models we don't recognize.
30
+ */
31
+ export declare function ratesFor(modelId: string): {
32
+ input: number;
33
+ output: number;
34
+ };
18
35
  export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
@@ -12,7 +12,40 @@ export const MODEL_REGISTRY = {
12
12
  export function getModelInfo(id) {
13
13
  return MODEL_REGISTRY[id] ?? { id, contextWindow: 100000, maxOutput: 4096, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: { tools: true } };
14
14
  }
15
+ /**
16
+ * Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
17
+ * prices live in exactly one place. Local/self-hosted entries are $0.
18
+ */
19
+ export const MODEL_RATES_PER_1K = Object.fromEntries(Object.entries(MODEL_REGISTRY).map(([k, v]) => [k, { input: v.inputPricePer1k, output: v.outputPricePer1k }]));
20
+ /** Hosted-family fallback rates for model ids not in the registry. */
21
+ const FAMILY_RATES = [
22
+ { test: (m) => /o1/i.test(m), input: 0.015, output: 0.06 },
23
+ { test: (m) => /gpt-3\.5/i.test(m), input: 0.0005, output: 0.0015 },
24
+ { test: (m) => /gpt-4/i.test(m), input: 0.003, output: 0.015 },
25
+ { test: (m) => /claude|anthropic/i.test(m), input: 0.003, output: 0.015 },
26
+ { test: (m) => /gemini/i.test(m), input: 0.00075, output: 0.003 },
27
+ ];
28
+ /** Local/self-hosted model ids are always priced at $0. */
29
+ const LOCAL_MODEL_PATTERN = /local|ollama|llama|mistral|mixtral|qwen|gemma|phi-|vllm|llama\.cpp|^mock/i;
30
+ /**
31
+ * Resolve per-1K rates for any model id. Exact registry hits win, then the
32
+ * local-model check ($0), then hosted-family fallbacks. Unknown models are
33
+ * $0 — never invent nonzero prices for models we don't recognize.
34
+ */
35
+ export function ratesFor(modelId) {
36
+ const exact = MODEL_RATES_PER_1K[modelId];
37
+ if (exact)
38
+ return exact;
39
+ if (LOCAL_MODEL_PATTERN.test(modelId))
40
+ return { input: 0, output: 0 };
41
+ const fam = FAMILY_RATES.find((r) => r.test(modelId));
42
+ if (fam)
43
+ return { input: fam.input, output: fam.output };
44
+ return { input: 0, output: 0 };
45
+ }
15
46
  export function estimateCost(modelId, inputTokens, outputTokens) {
16
- const info = getModelInfo(modelId);
17
- return (inputTokens / 1000) * info.inputPricePer1k + (outputTokens / 1000) * info.outputPricePer1k;
47
+ // Single source: delegate to ratesFor so registry, family fallbacks, and
48
+ // local-$0 rules live in exactly one place (no split-brain with getModelInfo).
49
+ const { input, output } = ratesFor(modelId);
50
+ return (inputTokens / 1000) * input + (outputTokens / 1000) * output;
18
51
  }
package/dist/repl.d.ts CHANGED
@@ -6,4 +6,10 @@
6
6
  *
7
7
  * Type ':quit' (or Ctrl-C / Ctrl-D) to exit.
8
8
  */
9
+ export interface Turn {
10
+ role: 'user' | 'assistant';
11
+ content: string;
12
+ }
9
13
  export declare function repl(system: string): Promise<void>;
14
+ /** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
15
+ export declare function trimHistory(history: Turn[]): void;
package/dist/repl.js CHANGED
@@ -10,6 +10,7 @@ import * as readline from 'node:readline/promises';
10
10
  import { stdin, exit } from 'node:process';
11
11
  import { streamToStdout, readBoundedText } from './chat.js';
12
12
  import { resolveProvider, providerHelp } from './providers.js';
13
+ import { estimateTokens } from './context/tokenizer.js';
13
14
  /** Cap conversation history so we don't blow the model's context window. */
14
15
  const MAX_HISTORY_TURNS = 40;
15
16
  /** Approximate char budget for the user side of history. */
@@ -26,6 +27,8 @@ export async function repl(system) {
26
27
  exit(2);
27
28
  }
28
29
  const { baseURL, apiKey, model, source } = provider;
30
+ // Legacy REPL — prefer `klyro tui`; history truncation is approximate.
31
+ process.stderr.write('klyro: legacy REPL — prefer `klyro tui`; history truncation is approximate.\n');
29
32
  const history = [];
30
33
  // Adapt readline to the environment. On a TTY we let readline manage the
31
34
  // prompt and echo so the user sees their typing; on a pipe we suppress both
@@ -94,7 +97,7 @@ export async function repl(system) {
94
97
  }
95
98
  }
96
99
  /** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
97
- function trimHistory(history) {
100
+ export function trimHistory(history) {
98
101
  // Keep even number of turns (pairs) when trimming for turn count
99
102
  while (history.length > MAX_HISTORY_TURNS) {
100
103
  // Drop oldest 2 (user+assistant) to preserve adjacency
@@ -106,21 +109,23 @@ function trimHistory(history) {
106
109
  history.shift();
107
110
  }
108
111
  }
109
- let total = 0;
110
- for (const t of history)
111
- total += t.content.length;
112
- while (total > MAX_HISTORY_CHARS && history.length > 2) {
112
+ // Token-budgeted trim (approximate): estimate over the joined history
113
+ // instead of raw chars so long-token text (CJK, code) trims correctly.
114
+ const budgetTokens = Math.ceil(MAX_HISTORY_CHARS / 4);
115
+ if (estimateTokens(history.map((t) => t.content).join('\n')) <= budgetTokens)
116
+ return;
117
+ while (history.length > 2) {
113
118
  // Drop oldest pair to avoid orphaning tool context
114
119
  const first = history[0];
115
120
  const second = history[1];
116
121
  if (!first)
117
122
  break;
118
- total -= first.content.length;
119
123
  history.shift();
120
124
  if (second) {
121
- total -= second.content.length;
122
125
  history.shift();
123
126
  }
127
+ if (estimateTokens(history.map((t) => t.content).join('\n')) <= budgetTokens)
128
+ break;
124
129
  }
125
130
  }
126
131
  /** Send the full history, stream the reply, return the assistant text. */
@@ -0,0 +1,9 @@
1
+ import type { ChildSummary } from '../../agent/orchestrator.js';
2
+ export declare const spawnAgentTool: import("../types.js").Tool<{
3
+ agent: string;
4
+ task: string;
5
+ cwd?: string | undefined;
6
+ model?: string | undefined;
7
+ timeoutMs?: number | undefined;
8
+ }, ChildSummary>;
9
+ export type SpawnAgentOutput = ChildSummary;