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.
- package/dist/agent/anthropic-adapter.js +2 -8
- package/dist/agent/provider-adapter.js +21 -2
- package/dist/agent/runtime.js +34 -8
- package/dist/persistence/store.d.ts +2 -0
- package/dist/persistence/store.js +33 -11
- package/dist/policy/path-guard.js +5 -7
- package/dist/policy/secret-redactor.js +6 -2
- package/dist/tools/fs/apply-patch.js +5 -5
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/search/grep.d.ts +20 -0
- package/dist/tools/search/grep.js +7 -0
- package/dist/tools/shell/background.js +25 -1
- package/dist/tools/shell/shell-exec.js +7 -0
- package/dist/verification/baseline.js +9 -2
- package/dist/verification/engine.js +14 -3
- package/dist/verification/scoped.js +13 -8
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
}
|
package/dist/agent/runtime.js
CHANGED
|
@@ -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:
|
|
190
|
-
messages:
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
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 {
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 {
|
|
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 } =
|
|
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
|
|
85
|
-
child.stderr.on('data', (b) => { stderr
|
|
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
|
|
34
|
-
child.stderr.on('data', (b) => { stderr
|
|
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
|
-
|
|
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
|
|
90
|
-
child.stderr.on('data', (b) => { stderr
|
|
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(
|
|
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(
|
|
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(
|
|
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); });
|
|
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)` };
|