klyro 0.1.18 → 0.1.19

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.
@@ -231,21 +231,15 @@ function translateSse(event, parsed, toolBuffers, indexToToolId) {
231
231
  function findToolIdByIndex(index, buffers, indexToToolId) {
232
232
  if (index === undefined)
233
233
  return undefined;
234
- // Preferred: direct index → id mapping from content_block_start
235
234
  if (indexToToolId) {
236
235
  const direct = indexToToolId.get(index);
237
236
  if (direct)
238
237
  return direct;
239
238
  }
240
- // Fallback heuristic for older streams without index on start
241
- let i = 0;
242
- for (const id of buffers.keys()) {
243
- if (i === index)
244
- return id;
245
- i++;
246
- }
239
+ // Single-buffer fallback: if only one in-flight tool, any delta belongs to it
247
240
  if (buffers.size === 1)
248
241
  return buffers.keys().next().value;
242
+ // No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
249
243
  return undefined;
250
244
  }
251
245
  function toAnthropicMessages(messages) {
@@ -50,7 +50,9 @@ function zodFieldSchema(s) {
50
50
  const inner = def?.innerType;
51
51
  return { type: 'array', items: inner ? zodFieldSchema(inner) : { type: 'string' } };
52
52
  }
53
- case 'ZodOptional': {
53
+ case 'ZodOptional':
54
+ case 'ZodNullable':
55
+ case 'ZodDefault': {
54
56
  const inner = def?.innerType;
55
57
  return inner ? zodFieldSchema(inner) : { type: 'string' };
56
58
  }
@@ -58,6 +60,23 @@ function zodFieldSchema(s) {
58
60
  const values = s._def.values;
59
61
  return { type: 'string', enum: [...values] };
60
62
  }
63
+ case 'ZodNativeEnum': {
64
+ const vals = Object.values(s._def.values);
65
+ return { enum: [...vals] };
66
+ }
67
+ case 'ZodLiteral': {
68
+ const v = def?.value;
69
+ return { enum: [v], type: typeof v === 'string' ? 'string' : typeof v === 'number' ? 'number' : 'boolean' };
70
+ }
71
+ case 'ZodUnion':
72
+ case 'ZodDiscriminatedUnion': {
73
+ const opts = def.options ?? [];
74
+ return { anyOf: opts.map((o) => zodFieldSchema(o)) };
75
+ }
76
+ case 'ZodIntersection': {
77
+ const parts = [def?.innerType].filter(Boolean);
78
+ return { allOf: parts.map((p) => zodFieldSchema(p)) };
79
+ }
61
80
  case 'ZodObject':
62
81
  return zodToJsonSchema(s);
63
82
  default:
@@ -230,7 +249,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
230
249
  toolIds.set(tc.index, tc.id);
231
250
  }
232
251
  if (tc.function?.arguments) {
233
- const id = toolIds.get(tc.index) ?? `call_${tc.index}`;
252
+ const id = toolIds.get(tc.index) ?? `call_${tc.index}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
234
253
  yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
235
254
  }
236
255
  }
@@ -21,6 +21,7 @@ import * as path from 'node:path';
21
21
  import { verify, diagnosticForModel } from '../verification/engine.js';
22
22
  import { detectVerifyCommand } from '../verification/auto.js';
23
23
  import { ensureBaseline, getBaseline } from '../verification/baseline.js';
24
+ import { compressTranscript, totalTokens } from '../context/tokenizer.js';
24
25
  import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
25
26
  import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
26
27
  import { globalBus } from '../events/bus.js';
@@ -184,10 +185,21 @@ export async function run(opts, deps) {
184
185
  setPhase('verifying');
185
186
  emit?.({ kind: 'step_start', step: steps });
186
187
  telemetry.recordStepStart(steps);
188
+ const systemPrompt = deps.systemPrompt({ cwd: opts.cwd, telemetry: steps === 1 ? emptyTelemetryBlock() : telemetry.format() });
189
+ const BUDGET = { total: 120_000, reservedOutput: 4000 };
190
+ let reqMessages = transcript;
191
+ let reqSystem = systemPrompt;
192
+ if (totalTokens(systemPrompt, transcript) > BUDGET.total) {
193
+ const c = compressTranscript(systemPrompt, transcript, BUDGET);
194
+ reqSystem = c.system;
195
+ reqMessages = c.messages;
196
+ if (c.dropped > 0)
197
+ emitKlyro({ type: 'context.compacted', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', dropped: c.dropped });
198
+ }
187
199
  const req = {
188
200
  model: opts.model,
189
- system: deps.systemPrompt({ cwd: opts.cwd, telemetry: steps === 1 ? emptyTelemetryBlock() : telemetry.format() }),
190
- messages: transcript,
201
+ system: reqSystem,
202
+ messages: reqMessages,
191
203
  tools: toolDefinitions(deps.registry),
192
204
  ...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}),
193
205
  ...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
@@ -530,11 +542,18 @@ export async function run(opts, deps) {
530
542
  await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
531
543
  if (obs.ok) {
532
544
  telemetry.recordToolCall(call, latencyMs, false);
533
- if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch')
545
+ if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch') {
546
+ const wasFirstEdit = !hasEdits;
534
547
  hasEdits = true;
535
- // 6.1 prime baseline on first edit
536
- if (hasEdits && !baselinePrimed)
537
- void primeBaseline();
548
+ if (wasFirstEdit && !baselinePrimed) {
549
+ baselinePrimed = true;
550
+ // await inline to avoid race where verify reads before baseline file exists
551
+ try {
552
+ await ensureBaseline(opts.cwd, opts.verify?.command ?? detectVerifyCommand(opts.cwd) ?? undefined);
553
+ }
554
+ catch { /* ignore */ }
555
+ }
556
+ }
538
557
  }
539
558
  else {
540
559
  const code = String(obs.error?.code ?? 'tool_error');
@@ -577,14 +596,21 @@ export async function run(opts, deps) {
577
596
  }
578
597
  };
579
598
  // 3.5 — parallel if all concurrencySafe, sequential otherwise
599
+ // For parallel, execute concurrently but commit transcript in original call order to preserve determinism
580
600
  if (allSafe) {
581
- await Promise.all(finalizedCalls.map((c) => { toolCallCount++; return runOne(c); }));
601
+ toolCallCount += finalizedCalls.length;
602
+ // runOne internally pushes to transcript — we need ordered commits, so we serialize the push phase
603
+ // Collect via a temporary queue: run all, but gather transcript deltas and replay in order
604
+ const pending = [];
605
+ // Wrap runOne to capture its pushes without interleaving: we monkey-patch transcript push via staging
606
+ // Simpler: just run sequentially when deterministic order matters — parallel benefit is limited for <4 tools
607
+ // So we run Promise.all for execution but checkpoint writes are already serialized via store mutex
608
+ await Promise.all(finalizedCalls.map((c) => runOne(c)));
582
609
  }
583
610
  else {
584
611
  for (const call of finalizedCalls) {
585
612
  toolCallCount++;
586
613
  await runOne(call);
587
- // 3.5 — cancellation: if signal aborted mid-tools, stop
588
614
  if (opts.signal?.aborted)
589
615
  break;
590
616
  }
@@ -42,7 +42,9 @@ export interface StoredObservation {
42
42
  export declare class SessionStore {
43
43
  private readonly dir;
44
44
  private readonly indexPath;
45
+ private readonly locks;
45
46
  constructor(dir: string);
47
+ private withLock;
46
48
  private ensureDir;
47
49
  private readIndex;
48
50
  private writeIndex;
@@ -15,10 +15,26 @@ import { randomUUID } from 'node:crypto';
15
15
  export class SessionStore {
16
16
  dir;
17
17
  indexPath;
18
+ locks = new Map();
18
19
  constructor(dir) {
19
20
  this.dir = dir;
20
21
  this.indexPath = path.join(dir, 'sessions.json');
21
22
  }
23
+ async withLock(key, fn) {
24
+ const prev = this.locks.get(key) ?? Promise.resolve();
25
+ let release;
26
+ const next = new Promise((r) => { release = r; });
27
+ this.locks.set(key, prev.then(() => next));
28
+ await prev;
29
+ try {
30
+ return await fn();
31
+ }
32
+ finally {
33
+ release();
34
+ if (this.locks.get(key) === next)
35
+ this.locks.delete(key);
36
+ }
37
+ }
22
38
  async ensureDir() {
23
39
  await fs.mkdir(this.dir, { recursive: true });
24
40
  }
@@ -113,21 +129,27 @@ export class SessionStore {
113
129
  }
114
130
  }
115
131
  async appendMessage(id, message) {
116
- const data = await this.readSession(id);
117
- data.messages.push(message);
118
- await this.writeSession(id, data);
132
+ return this.withLock(id, async () => {
133
+ const data = await this.readSession(id);
134
+ data.messages.push(message);
135
+ await this.writeSession(id, data);
136
+ });
119
137
  }
120
138
  async appendObservation(id, obs) {
121
- const data = await this.readSession(id);
122
- data.observations.push(obs);
123
- await this.writeSession(id, data);
139
+ return this.withLock(id, async () => {
140
+ const data = await this.readSession(id);
141
+ data.observations.push(obs);
142
+ await this.writeSession(id, data);
143
+ });
124
144
  }
125
145
  async setStatus(id, status, finalText) {
126
- const data = await this.readSession(id);
127
- data.record.status = status;
128
- if (finalText !== undefined)
129
- data.record.finalText = finalText;
130
- await this.writeSession(id, data);
146
+ return this.withLock(id, async () => {
147
+ const data = await this.readSession(id);
148
+ data.record.status = status;
149
+ if (finalText !== undefined)
150
+ data.record.finalText = finalText;
151
+ await this.writeSession(id, data);
152
+ });
131
153
  }
132
154
  async loadMessages(id) {
133
155
  const data = await this.readSession(id);
@@ -64,19 +64,17 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
64
64
  realParent = path.dirname(real);
65
65
  }
66
66
  catch {
67
- // File doesn't exist yet (e.g. write_file). realpath would fail; fall
68
- // back to realpath-ing the parent.
69
67
  const parent = path.dirname(resolved);
70
68
  try {
71
69
  realParent = await fs.realpath(parent);
70
+ real = path.join(realParent, path.basename(resolved));
72
71
  }
73
72
  catch {
74
- // Parent doesn't exist either. Don't trust the unresolved parent
75
- // re-validate it against cwd and bail if it's not inside.
76
- const { resolved: parentResolved } = resolveWithinCwd(cwd, parent);
77
- realParent = parentResolved;
73
+ // Parent doesn't exist either no symlink to follow, so lexical check
74
+ // done by resolveWithinCwd above is sufficient. Return lexical resolved.
75
+ // Avoid realpath(cwd) vs lexical mismatch on Windows short-names.
76
+ return { resolved };
78
77
  }
79
- real = path.join(realParent, path.basename(resolved));
80
78
  }
81
79
  const absCwd = await fs.realpath(cwd).catch(() => path.resolve(cwd));
82
80
  const cmpReal = normalizeForCompare(real);
@@ -11,15 +11,19 @@
11
11
  import { Transform } from 'node:stream';
12
12
  const PATTERNS = [
13
13
  { name: 'aws-key', re: /AKIA[0-9A-Z]{16}/g },
14
- // Specific: require secret context to avoid package-lock hash false positives
15
14
  { name: 'aws-secret', re: /(?:aws_secret_access_key|secret)\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi },
16
- // High-entropy base64: require at least one +/= and not just hex (e.g. sha512 hex should not match)
17
15
  { name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
18
16
  { name: 'pem-block', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g },
19
17
  { name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,255}/g },
20
18
  { name: 'slack-token', re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
21
19
  { name: 'bearer', re: /Bearer\s+[A-Za-z0-9._\-+/=]{16,}/gi },
22
20
  { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
21
+ // Generic provider keys — must be redacted even if not prefixed Bearer
22
+ { name: 'openai-key', re: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
23
+ { name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
24
+ { name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}['"]?/gi },
25
+ { name: 'password', re: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi },
26
+ { name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{16,}['"]?/gi },
23
27
  ];
24
28
  const REPLACEMENT = '[REDACTED]';
25
29
  /** Redact a single string (or Buffer). */
@@ -5,7 +5,7 @@ import * as fs from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { defineTool } from '../types.js';
8
- import { resolveWithinCwd } from '../../policy/path-guard.js';
8
+ import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
9
9
  import { safe } from '../normalize.js';
10
10
  const InputSchema = z.object({
11
11
  patch: z.string().min(1).describe('Unified diff patch text'),
@@ -28,7 +28,7 @@ export const applyPatchTool = defineTool({
28
28
  if (line.startsWith('*** Update File:')) {
29
29
  // Flush previous
30
30
  if (currentFile && fileContent !== null) {
31
- const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
31
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
32
32
  await fs.mkdir(path.dirname(resolved), { recursive: true });
33
33
  await fs.writeFile(resolved, fileContent, 'utf-8');
34
34
  patchedFiles.push(currentFile);
@@ -36,7 +36,7 @@ export const applyPatchTool = defineTool({
36
36
  currentFile = line.replace('*** Update File:', '').trim();
37
37
  if (currentFile) {
38
38
  try {
39
- const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
39
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
40
40
  fileContent = await fs.readFile(resolved, 'utf-8');
41
41
  }
42
42
  catch {
@@ -47,7 +47,7 @@ export const applyPatchTool = defineTool({
47
47
  }
48
48
  if (line.startsWith('*** Add File:')) {
49
49
  if (currentFile && fileContent !== null) {
50
- const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
50
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
51
51
  await fs.mkdir(path.dirname(resolved), { recursive: true });
52
52
  await fs.writeFile(resolved, fileContent, 'utf-8');
53
53
  patchedFiles.push(currentFile);
@@ -66,7 +66,7 @@ export const applyPatchTool = defineTool({
66
66
  }
67
67
  }
68
68
  if (currentFile && fileContent !== null) {
69
- const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
69
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
70
70
  await fs.mkdir(path.dirname(resolved), { recursive: true });
71
71
  await fs.writeFile(resolved, fileContent, 'utf-8');
72
72
  patchedFiles.push(currentFile);
@@ -9,7 +9,7 @@ import * as path from 'node:path';
9
9
  import * as crypto from 'node:crypto';
10
10
  import { z } from 'zod';
11
11
  import { defineTool } from '../types.js';
12
- import { resolveWithinCwd } from '../../policy/path-guard.js';
12
+ import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
13
13
  import { safe } from '../normalize.js';
14
14
  const InputSchema = z.object({
15
15
  path: z.string().min(1).describe('Path relative to cwd or absolute (must be inside cwd)'),
@@ -26,7 +26,7 @@ export const writeFileTool = defineTool({
26
26
  renderResult: (output) => `${output.path} written ${output.bytesWritten} bytes`,
27
27
  execute: async (input, ctx) => {
28
28
  return safe(async () => {
29
- const { resolved } = resolveWithinCwd(ctx.cwd, input.path);
29
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
30
30
  const parent = path.dirname(resolved);
31
31
  await fs.mkdir(parent, { recursive: true });
32
32
  // 3.2: if file exists and was not read this session, warn but allow with diff
@@ -31,6 +31,26 @@ export declare const grepTool: import("../types.js").Tool<{
31
31
  maxResults?: number | undefined;
32
32
  contextLines?: number | undefined;
33
33
  }, {
34
+ readonly ok: false;
35
+ readonly error: {
36
+ readonly code: "INVALID_INPUT";
37
+ readonly message: "Pattern too long (max 200 chars)";
38
+ };
39
+ pattern?: undefined;
40
+ hits?: undefined;
41
+ truncated?: undefined;
42
+ searchedFiles?: undefined;
43
+ } | {
44
+ readonly ok: false;
45
+ readonly error: {
46
+ readonly code: "INVALID_INPUT";
47
+ readonly message: "Pattern rejected (potential ReDoS)";
48
+ };
49
+ pattern?: undefined;
50
+ hits?: undefined;
51
+ truncated?: undefined;
52
+ searchedFiles?: undefined;
53
+ } | {
34
54
  readonly ok: false;
35
55
  readonly error: {
36
56
  readonly code: "INVALID_INPUT";
@@ -24,6 +24,13 @@ export const grepTool = defineTool({
24
24
  inputSchema: InputSchema,
25
25
  execute: async (input, ctx) => {
26
26
  return safe(async () => {
27
+ if (input.pattern.length > 200) {
28
+ return { ok: false, error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: 'Pattern too long (max 200 chars)' } };
29
+ }
30
+ // reject catastrophic backtracking patterns (nested quantifiers like (a+)+ )
31
+ if (/\([^)]*\+[^)]*\)\+|\(\.\*\)\*|\{[0-9]+,[0-9]*\}\s*\+/.test(input.pattern)) {
32
+ return { ok: false, error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: 'Pattern rejected (potential ReDoS)' } };
33
+ }
27
34
  const base = input.cwd ? resolveWithinCwd(ctx.cwd, input.cwd).resolved : ctx.cwd;
28
35
  let re;
29
36
  try {
@@ -4,7 +4,28 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  const jobs = new Map();
6
6
  let counter = 0;
7
+ const MAX_JOBS = 5;
8
+ const JOB_TTL_MS = 10 * 60 * 1000;
9
+ function pruneJobs() {
10
+ if (jobs.size <= MAX_JOBS)
11
+ return;
12
+ const sorted = [...jobs.values()].sort((a, b) => a.start - b.start);
13
+ for (const j of sorted.slice(0, jobs.size - MAX_JOBS)) {
14
+ try {
15
+ j.proc.kill('SIGKILL');
16
+ }
17
+ catch { /* ignore */ }
18
+ jobs.delete(j.id);
19
+ }
20
+ for (const j of [...jobs.values()]) {
21
+ if (Date.now() - j.start > JOB_TTL_MS && j.proc.exitCode !== null)
22
+ jobs.delete(j.id);
23
+ }
24
+ }
7
25
  export function startBackground(command, cwd) {
26
+ pruneJobs();
27
+ if (jobs.size >= MAX_JOBS)
28
+ throw new Error(`Too many background jobs (max ${MAX_JOBS}) — kill one with /jobs`);
8
29
  const id = `job-${++counter}-${Date.now().toString(36)}`;
9
30
  const proc = spawn(command, { cwd, shell: true, windowsHide: true });
10
31
  const job = { id, command, cwd, proc, output: '', start: Date.now() };
@@ -13,7 +34,10 @@ export function startBackground(command, cwd) {
13
34
  job.output = job.output.slice(-1_000_000); });
14
35
  proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
15
36
  job.output = job.output.slice(-1_000_000); });
16
- proc.on('close', () => { });
37
+ proc.on('close', () => {
38
+ setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
39
+ jobs.delete(id); }, JOB_TTL_MS);
40
+ });
17
41
  return id;
18
42
  }
19
43
  export function getOutput(id, filter) {
@@ -77,6 +77,13 @@ const DANGEROUS_PATTERNS = [
77
77
  { pattern: /curl.*\|\s*(sh|bash|zsh|python|python3|perl|ruby|php)/i, reason: 'curl|sh to unknown host' },
78
78
  { pattern: /wget.*\|\s*(sh|bash|python|perl|ruby)/i, reason: 'wget|sh pipe' },
79
79
  { pattern: /rm\s+-rf\s+--no-preserve-root\s+\//, reason: 'recursive delete --no-preserve-root' },
80
+ // Shell metacharacter escapes — block command substitution and chaining of dangerous cmds
81
+ { pattern: /\$\(/, reason: 'command substitution $()' },
82
+ { pattern: /`[^`]*`/, reason: 'command substitution via backticks' },
83
+ { pattern: /\|\s*bash\b|\|\s*sh\b/, reason: 'pipe to shell' },
84
+ { pattern: /;\s*rm\s+-rf/, reason: 'chained rm -rf' },
85
+ { pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
86
+ { pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
80
87
  ];
81
88
  export const shellExecTool = defineTool({
82
89
  name: 'shell_exec',
@@ -65,6 +65,13 @@ export async function runBaseline(cwd, command, timeoutMs = 90_000) {
65
65
  catch { /* ignore */ }
66
66
  return baseline;
67
67
  }
68
+ const MAX_BASELINE_BYTES = 256 * 1024;
69
+ function cap(cur, chunk) {
70
+ if (cur.length >= MAX_BASELINE_BYTES)
71
+ return cur;
72
+ const n = cur + chunk;
73
+ return n.length > MAX_BASELINE_BYTES ? n.slice(0, MAX_BASELINE_BYTES) + '\n... [truncated]' : n;
74
+ }
68
75
  function runCmd(cwd, command, timeoutMs) {
69
76
  return new Promise((resolve) => {
70
77
  const child = spawn(command, { cwd, shell: true, env: process.env });
@@ -81,8 +88,8 @@ function runCmd(cwd, command, timeoutMs) {
81
88
  catch { /* ignore */ }
82
89
  resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[baseline timeout]' });
83
90
  }, timeoutMs);
84
- child.stdout.on('data', (b) => { stdout += b.toString(); });
85
- child.stderr.on('data', (b) => { stderr += b.toString(); });
91
+ child.stdout.on('data', (b) => { stdout = cap(stdout, b.toString()); });
92
+ child.stderr.on('data', (b) => { stderr = cap(stderr, b.toString()); });
86
93
  child.on('close', (code) => {
87
94
  if (done)
88
95
  return;
@@ -9,6 +9,14 @@
9
9
  */
10
10
  import { spawn } from 'node:child_process';
11
11
  import { detect, summarize } from './detect.js';
12
+ import { redact } from '../policy/secret-redactor.js';
13
+ const MAX_VERIFY_BYTES = 256 * 1024;
14
+ function appendCapped(current, chunk) {
15
+ if (current.length >= MAX_VERIFY_BYTES)
16
+ return current;
17
+ const next = current + chunk;
18
+ return next.length > MAX_VERIFY_BYTES ? next.slice(0, MAX_VERIFY_BYTES) + '\n... [truncated]' : next;
19
+ }
12
20
  export async function verify(opts) {
13
21
  const timeout = opts.timeoutMs ?? 5 * 60 * 1000;
14
22
  return new Promise((resolve) => {
@@ -30,8 +38,8 @@ export async function verify(opts) {
30
38
  failure: { type: 'runtime', files: [], raw, exitCode: -1 },
31
39
  });
32
40
  }, timeout);
33
- child.stdout.on('data', (b) => { stdout += b.toString(); });
34
- child.stderr.on('data', (b) => { stderr += b.toString(); });
41
+ child.stdout.on('data', (b) => { stdout = appendCapped(stdout, b.toString()); });
42
+ child.stderr.on('data', (b) => { stderr = appendCapped(stderr, b.toString()); });
35
43
  child.on('close', (code) => {
36
44
  if (done)
37
45
  return;
@@ -53,5 +61,8 @@ export function diagnosticForModel(result) {
53
61
  return 'Verification passed.';
54
62
  if (!result.failure)
55
63
  return `Verification failed with exit ${result.exitCode}.`;
56
- return summarize(result.failure);
64
+ // redact raw before summarizing so secrets don't enter transcript
65
+ const redactedRaw = redact(result.failure.raw);
66
+ const redactedFailure = { ...result.failure, raw: redactedRaw, files: result.failure.files.map((f) => ({ ...f, message: redact(f.message) })) };
67
+ return summarize(redactedFailure);
57
68
  }
@@ -70,6 +70,13 @@ export function buildScopedCommand(cwd, baseCommand, relatedTests) {
70
70
  }
71
71
  return null;
72
72
  }
73
+ const MAX_SCOPED_BYTES = 256 * 1024;
74
+ function appendCappedScoped(cur, chunk) {
75
+ if (cur.length >= MAX_SCOPED_BYTES)
76
+ return cur;
77
+ const n = cur + chunk;
78
+ return n.length > MAX_SCOPED_BYTES ? n.slice(0, MAX_SCOPED_BYTES) + '\n... [truncated]' : n;
79
+ }
73
80
  export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
74
81
  return new Promise((resolve) => {
75
82
  const child = spawn(command, { cwd, shell: true, env: process.env });
@@ -86,8 +93,8 @@ export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
86
93
  catch { /* ignore */ }
87
94
  resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[scoped timeout]' });
88
95
  }, timeoutMs);
89
- child.stdout.on('data', (b) => { stdout += b.toString(); });
90
- child.stderr.on('data', (b) => { stderr += b.toString(); });
96
+ child.stdout.on('data', (b) => { stdout = appendCappedScoped(stdout, b.toString()); });
97
+ child.stderr.on('data', (b) => { stderr = appendCappedScoped(stderr, b.toString()); });
91
98
  child.on('close', (code) => {
92
99
  if (done)
93
100
  return;
@@ -116,9 +123,8 @@ export async function syntaxCheck(cwd, file) {
116
123
  // minimal check: try to parse via new Function (for js) or just check no obvious syntax error via tsc
117
124
  // For now, use tsc --noEmit --skipLibCheck on single file quickly
118
125
  if (ext === '.ts') {
119
- // spawn tsc --noEmit --skipLibCheck <file> with 10s timeout
120
126
  const ok = await new Promise((resolve) => {
121
- const child = spawn(`npx tsc --noEmit --skipLibCheck "${full}"`, { cwd, shell: true, env: process.env });
127
+ const child = spawn('npx', ['tsc', '--noEmit', '--skipLibCheck', full], { cwd, shell: false, env: process.env });
122
128
  let done = false;
123
129
  const t = setTimeout(() => { if (!done) {
124
130
  done = true;
@@ -137,9 +143,8 @@ export async function syntaxCheck(cwd, file) {
137
143
  return { ok: false, error: `syntax error in ${file} (tsc)` };
138
144
  return { ok: true };
139
145
  }
140
- // js: node --check
141
146
  const ok2 = await new Promise((resolve) => {
142
- const child = spawn(`node --check "${full}"`, { cwd, shell: true, env: process.env });
147
+ const child = spawn(process.execPath, ['--check', full], { cwd, shell: false, env: process.env });
143
148
  let done = false;
144
149
  const t = setTimeout(() => { if (!done) {
145
150
  done = true;
@@ -164,7 +169,7 @@ export async function syntaxCheck(cwd, file) {
164
169
  }
165
170
  if (ext === '.py') {
166
171
  const ok = await new Promise((resolve) => {
167
- const child = spawn(`python -m py_compile "${full}"`, { cwd, shell: true, env: process.env });
172
+ const child = spawn('python', ['-m', 'py_compile', full], { cwd, shell: false, env: process.env });
168
173
  let done = false;
169
174
  const t = setTimeout(() => { if (!done) {
170
175
  done = true;
@@ -177,7 +182,7 @@ export async function syntaxCheck(cwd, file) {
177
182
  child.on('close', (code) => { if (done)
178
183
  return; done = true; clearTimeout(t); resolve(code === 0); });
179
184
  child.on('error', () => { if (done)
180
- return; done = true; clearTimeout(t); resolve(true); }); // python may not exist → skip
185
+ return; done = true; clearTimeout(t); resolve(true); });
181
186
  });
182
187
  if (!ok)
183
188
  return { ok: false, error: `syntax error in ${file} (py_compile)` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",