mcp-castor 2026.3.0
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/README.md +487 -0
- package/bin/castor.js +706 -0
- package/index.js +206 -0
- package/package.json +97 -0
- package/skills/canary-test-staging/SKILL.md +24 -0
- package/skills/evo-mutation-rollback/SKILL.md +29 -0
- package/skills/hypothesis-generation/SKILL.md +26 -0
- package/skills/traceback-condensing/SKILL.md +26 -0
- package/src/castor_runner.js +469 -0
- package/src/config.js +1204 -0
- package/src/env.js +10 -0
- package/src/evo_engine.js +214 -0
- package/src/harness/core/events.js +75 -0
- package/src/harness/core/kernel.js +209 -0
- package/src/harness/evo/evaluator.js +156 -0
- package/src/harness/evo/evo_operator.js +550 -0
- package/src/harness/evo/lineage_dag.js +383 -0
- package/src/harness/evo/trace_repair.js +173 -0
- package/src/harness/evo/watchdog.js +72 -0
- package/src/harness/loop_detector.js +135 -0
- package/src/harness/runner.js +1216 -0
- package/src/harness/services/ast_service.js +1813 -0
- package/src/harness/services/event_logger.js +275 -0
- package/src/harness/services/mcp_bridge.js +408 -0
- package/src/harness/services/provider_vllm.js +728 -0
- package/src/harness/services/sandbox_fs.js +1238 -0
- package/src/harness/services/searxng_lifecycle.js +254 -0
- package/src/harness/services/shell_executor.js +264 -0
- package/src/harness/services/shell_validator.js +506 -0
- package/src/harness/services/web_service.js +828 -0
- package/src/platform.js +344 -0
- package/src/repetition_detector.js +139 -0
- package/src/semaphore.js +373 -0
- package/src/server_lifecycle.js +781 -0
- package/src/skills.js +400 -0
- package/src/state_pruner.js +392 -0
- package/src/task_registry.js +1357 -0
- package/src/telemetry.js +638 -0
- package/src/tools.js +997 -0
- package/src/wsl_bridge.js +629 -0
- package/src/wsl_env.js +171 -0
- package/stream_proxy.js +453 -0
|
@@ -0,0 +1,1238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed Filesystem Service.
|
|
3
|
+
*
|
|
4
|
+
* Provides sandboxed file operations (read, write, edit, patch, list, search)
|
|
5
|
+
* confined to a workspace root.
|
|
6
|
+
*
|
|
7
|
+
* - Enforces a strict ignore policy (auto-skips .venv, node_modules, .git,
|
|
8
|
+
* __pycache__, and other build/vendor directories).
|
|
9
|
+
* - Normalizes paths across Windows and WSL mounts.
|
|
10
|
+
* - Performs AST-validated file edits and bounded slice reads.
|
|
11
|
+
* - Runs indexed searches (git grep) with a safe fallback walk.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { IS_WINDOWS } from "../../config.js";
|
|
19
|
+
import { normalizeWorkspacePath, toWindowsPath, toPosixWslPath, canonicalizePath } from "../../wsl_bridge.js";
|
|
20
|
+
import { fileTypeFromBuffer, reasonableDetectionSizeInBytes } from "file-type";
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Raised when `git grep` fails with a fatal error (any non-zero exit code
|
|
26
|
+
* other than 1) inside a git repository. Exit code 1 means "no matches" and
|
|
27
|
+
* is a valid empty result, not an error.
|
|
28
|
+
*
|
|
29
|
+
* @extends Error
|
|
30
|
+
* @property {number|null} code The git process exit code (e.g. 128).
|
|
31
|
+
* @property {string} stderr The trimmed stderr output from git.
|
|
32
|
+
*/
|
|
33
|
+
export class GitGrepError extends Error {
|
|
34
|
+
constructor(message, { code = null, stderr = "" } = {}) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "GitGrepError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.stderr = stderr;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Raised when a `patch` payload to `applyPatch()` exceeds `MAX_PATCH_SIZE`.
|
|
44
|
+
* The check runs before any `git` subprocess is spawned.
|
|
45
|
+
*
|
|
46
|
+
* @extends Error
|
|
47
|
+
* @property {number} size The actual byte length of the patch payload.
|
|
48
|
+
* @property {number} limit The maximum allowed byte length.
|
|
49
|
+
*/
|
|
50
|
+
export class PatchTooLargeError extends Error {
|
|
51
|
+
constructor(message, { size = 0, limit = 0 } = {}) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "PatchTooLargeError";
|
|
54
|
+
this.size = size;
|
|
55
|
+
this.limit = limit;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Raised when in-memory syntax validation fails before writing an edit to disk.
|
|
61
|
+
* Preserves disk pristine state (no bytes modified).
|
|
62
|
+
*/
|
|
63
|
+
export class SyntaxValidationError extends Error {
|
|
64
|
+
constructor(filePath, reason) {
|
|
65
|
+
super(
|
|
66
|
+
`SyntaxValidationError: In-memory syntax validation failed for '${filePath}': ${reason}. File was kept pristine (no changes written to disk).`
|
|
67
|
+
);
|
|
68
|
+
this.name = "SyntaxValidationError";
|
|
69
|
+
this.file = filePath;
|
|
70
|
+
this.reason = reason;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Raised when the `git apply` subprocess is killed by a timeout or signal
|
|
76
|
+
* rather than failing on patch content. Node reports a timeout as
|
|
77
|
+
* `err.code === "ETIMEDOUT"` and `err.signal === "SIGTERM"` (and
|
|
78
|
+
* `err.killed === true` on some platforms); this error is emitted for any of
|
|
79
|
+
* those. It is distinct from the generic `GitApplyError` so a hang/timeout is
|
|
80
|
+
* distinguishable from a normal "patch does not apply" failure.
|
|
81
|
+
*
|
|
82
|
+
* @extends Error
|
|
83
|
+
* @property {string|null} signal The termination signal (e.g. "SIGTERM").
|
|
84
|
+
* @property {string} stderr The trimmed stderr output from git, if any.
|
|
85
|
+
*/
|
|
86
|
+
export class GitApplyTimeoutError extends Error {
|
|
87
|
+
constructor(message, { signal = null, stderr = "" } = {}) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "GitApplyTimeoutError";
|
|
90
|
+
this.signal = signal;
|
|
91
|
+
this.stderr = stderr;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Raised by `readFile()` when the target is a binary file (detected by
|
|
97
|
+
* extension denylist or magic bytes). A text-only model must not ingest
|
|
98
|
+
* binary content; the error names the file, the detected type, and the
|
|
99
|
+
* instruction to extract text via a format-appropriate tool instead.
|
|
100
|
+
*
|
|
101
|
+
* @extends Error
|
|
102
|
+
* @property {string} file The resolved file path that was rejected.
|
|
103
|
+
* @property {string} detectedType The detected type label (mime/ext or the
|
|
104
|
+
* denylist reason).
|
|
105
|
+
* @property {string} reason Why the file was classified as binary.
|
|
106
|
+
*/
|
|
107
|
+
export class BinaryFileError extends Error {
|
|
108
|
+
constructor(message, { file = "", detectedType = "binary", reason = "" } = {}) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "BinaryFileError";
|
|
111
|
+
this.file = file;
|
|
112
|
+
this.detectedType = detectedType;
|
|
113
|
+
this.reason = reason;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Returns true for `.env` and `.env.*` files (e.g. `.env.local`,
|
|
119
|
+
* `.env.production`). These are excluded from the fallback directory walk to
|
|
120
|
+
* prevent secret ingestion when `git grep` is unavailable.
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
function isEnvFile(name) {
|
|
125
|
+
return name === ".env" || name.startsWith(".env.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extension denylist used as a fast-path binary pre-filter. A cheap,
|
|
130
|
+
* synchronous check that catches common binary formats by extension before
|
|
131
|
+
* any bytes are read. It is not the sole gate: `detectBinaryType()` performs
|
|
132
|
+
* an authoritative magic-byte check (via the `file-type` library) on the
|
|
133
|
+
* first KB of the file, which catches extensionless binaries. The set is a
|
|
134
|
+
* denylist, not an allowlist — an unknown extension is not treated as
|
|
135
|
+
* binary here.
|
|
136
|
+
* @type {Set<string>}
|
|
137
|
+
*/
|
|
138
|
+
const BINARY_EXTENSIONS = new Set([
|
|
139
|
+
"pdf",
|
|
140
|
+
"png",
|
|
141
|
+
"jpg",
|
|
142
|
+
"jpeg",
|
|
143
|
+
"webp",
|
|
144
|
+
"gif",
|
|
145
|
+
"ico",
|
|
146
|
+
"zip",
|
|
147
|
+
"gz",
|
|
148
|
+
"tar",
|
|
149
|
+
"7z",
|
|
150
|
+
"bz2",
|
|
151
|
+
"xz",
|
|
152
|
+
"wasm",
|
|
153
|
+
"exe",
|
|
154
|
+
"dll",
|
|
155
|
+
"so",
|
|
156
|
+
"dylib",
|
|
157
|
+
"bin",
|
|
158
|
+
"mp3",
|
|
159
|
+
"mp4",
|
|
160
|
+
"avi",
|
|
161
|
+
"mov",
|
|
162
|
+
"wav",
|
|
163
|
+
"woff",
|
|
164
|
+
"woff2",
|
|
165
|
+
"ttf",
|
|
166
|
+
"otf",
|
|
167
|
+
"sqlite",
|
|
168
|
+
"db",
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Returns true when `name`'s extension (case-insensitive) is in the binary
|
|
173
|
+
* denylist. A name with no extension returns false (the magic-byte check is
|
|
174
|
+
* the authoritative gate for those).
|
|
175
|
+
* @param {string} name
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
export function isBinaryExtension(name) {
|
|
179
|
+
const dot = name.lastIndexOf(".");
|
|
180
|
+
if (dot < 0 || dot === name.length - 1) return false;
|
|
181
|
+
const ext = name.slice(dot + 1).toLowerCase();
|
|
182
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Authoritative binary detection via magic bytes, using the `file-type`
|
|
187
|
+
* library. Reads at most the first `reasonableDetectionSizeInBytes` (4100)
|
|
188
|
+
* bytes of the file and returns the detected `{ ext, mime }` descriptor, or
|
|
189
|
+
* `null` when the bytes do not match any known binary signature (i.e. the
|
|
190
|
+
* file is plausibly text).
|
|
191
|
+
*
|
|
192
|
+
* @param {string} filePath
|
|
193
|
+
* @returns {Promise<{ext:string,mime:string}|null>}
|
|
194
|
+
*/
|
|
195
|
+
async function detectBinaryType(filePath) {
|
|
196
|
+
let fd;
|
|
197
|
+
try {
|
|
198
|
+
fd = fs.openSync(filePath, "r");
|
|
199
|
+
const size = fs.fstatSync(fd).size;
|
|
200
|
+
const len = Math.min(size, reasonableDetectionSizeInBytes);
|
|
201
|
+
if (len === 0) return null; // empty file is not a binary
|
|
202
|
+
const buf = Buffer.alloc(len);
|
|
203
|
+
fs.readSync(fd, buf, 0, len, 0);
|
|
204
|
+
return await fileTypeFromBuffer(buf);
|
|
205
|
+
} catch {
|
|
206
|
+
// Unreadable / vanished file: treat as non-binary here; the caller's own
|
|
207
|
+
// read will surface the real I/O error.
|
|
208
|
+
return null;
|
|
209
|
+
} finally {
|
|
210
|
+
if (fd !== undefined) {
|
|
211
|
+
try {
|
|
212
|
+
fs.closeSync(fd);
|
|
213
|
+
} catch {
|
|
214
|
+
// best-effort close
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Combined binary gate. Returns a human-readable reason string when the file
|
|
222
|
+
* is binary, or `null` when it is plausibly text.
|
|
223
|
+
*
|
|
224
|
+
* Order of checks:
|
|
225
|
+
* 1. Extension denylist fast-path (cheap, synchronous).
|
|
226
|
+
* 2. Magic-byte check via `file-type` on the first KB (authoritative;
|
|
227
|
+
* catches extensionless binaries).
|
|
228
|
+
*
|
|
229
|
+
* @param {string} filePath
|
|
230
|
+
* @returns {Promise<string|null>}
|
|
231
|
+
*/
|
|
232
|
+
async function classifyBinary(filePath) {
|
|
233
|
+
if (isBinaryExtension(path.basename(filePath))) {
|
|
234
|
+
return `extension '${path.basename(filePath)}' is a known binary type`;
|
|
235
|
+
}
|
|
236
|
+
const detected = await detectBinaryType(filePath);
|
|
237
|
+
if (detected) {
|
|
238
|
+
return `magic bytes identify it as ${detected.mime} (extension '${detected.ext}')`;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Classifies the line-ending style of a string.
|
|
245
|
+
*
|
|
246
|
+
* Returns:
|
|
247
|
+
* - "crlf" -> contains CRLF and no bare LF
|
|
248
|
+
* - "lf" -> contains bare LF and no CRLF
|
|
249
|
+
* - "mixed" -> contains both CRLF and bare LF
|
|
250
|
+
* - null -> contains no line endings at all
|
|
251
|
+
*
|
|
252
|
+
* Note: `\n` is a substring of `\r\n`, so a bare-LF check must first strip
|
|
253
|
+
* all CRLF sequences to avoid miscounting.
|
|
254
|
+
* @param {string} text
|
|
255
|
+
* @returns {"crlf"|"lf"|"mixed"|null}
|
|
256
|
+
*/
|
|
257
|
+
function lineEndingStyle(text) {
|
|
258
|
+
const hasCRLF = text.includes("\r\n");
|
|
259
|
+
const hasBareLF = text.replace(/\r\n/g, "").includes("\n");
|
|
260
|
+
if (hasCRLF && hasBareLF) return "mixed";
|
|
261
|
+
if (hasCRLF) return "crlf";
|
|
262
|
+
if (hasBareLF) return "lf";
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Normalizes a string's line endings to a target style.
|
|
268
|
+
* - "crlf" -> every line ending becomes CRLF
|
|
269
|
+
* - "lf" -> every CRLF becomes LF
|
|
270
|
+
* - "mixed" or null -> left unchanged (no single style to normalize to)
|
|
271
|
+
* @param {string} text
|
|
272
|
+
* @param {"crlf"|"lf"|"mixed"|null} style
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
function normalizeLineEndings(text, style) {
|
|
276
|
+
if (style === "crlf") return text.replace(/\r?\n/g, "\r\n");
|
|
277
|
+
if (style === "lf") return text.replace(/\r\n/g, "\n");
|
|
278
|
+
return text;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export const DEFAULT_IGNORED_DIRS = new Set([
|
|
282
|
+
".venv",
|
|
283
|
+
"venv",
|
|
284
|
+
"node_modules",
|
|
285
|
+
".git",
|
|
286
|
+
"__pycache__",
|
|
287
|
+
"target",
|
|
288
|
+
"dist",
|
|
289
|
+
"build",
|
|
290
|
+
"vendor",
|
|
291
|
+
".idea",
|
|
292
|
+
".vscode",
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Maximum byte length of a `patch` payload accepted by `applyPatch()`.
|
|
297
|
+
* Enforced before spawning `git` (see `PatchTooLargeError`). Bounds memory
|
|
298
|
+
* and the child-process input pipe for pathological payloads.
|
|
299
|
+
* - Unit: bytes
|
|
300
|
+
* - Default: 2097152 (2 MB)
|
|
301
|
+
* - Override: None
|
|
302
|
+
* @type {number}
|
|
303
|
+
*/
|
|
304
|
+
export const MAX_PATCH_SIZE = 2 * 1024 * 1024; // 2 MB
|
|
305
|
+
|
|
306
|
+
let _sharedAstService = null;
|
|
307
|
+
async function getSharedAstService() {
|
|
308
|
+
if (!_sharedAstService) {
|
|
309
|
+
const { AstService } = await import("./ast_service.js");
|
|
310
|
+
_sharedAstService = new AstService();
|
|
311
|
+
}
|
|
312
|
+
return _sharedAstService;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export class SandboxFsService {
|
|
316
|
+
constructor(options = {}) {
|
|
317
|
+
// Canonicalize the sandbox root through the OS symlink/junction resolution
|
|
318
|
+
// layer so a junction/symlink cwd is stored as its real path. Containment
|
|
319
|
+
// checks then compare realpath(target) against a real root, eliminating
|
|
320
|
+
// false SymlinkEscapeError/PathEscapeError while still catching real
|
|
321
|
+
// escapes.
|
|
322
|
+
const rawRoot = options.root ? normalizeWorkspacePath(options.root) : process.cwd();
|
|
323
|
+
this.root = canonicalizePath(rawRoot);
|
|
324
|
+
this.ignoredDirs = new Set([...DEFAULT_IGNORED_DIRS, ...(options.ignoredDirs || [])]);
|
|
325
|
+
// Timeout for the `git apply` subprocess in `applyPatch()`. Defaults to
|
|
326
|
+
// 15s; overridable (e.g. by tests) to exercise the timeout path.
|
|
327
|
+
this.gitApplyTimeoutMs =
|
|
328
|
+
typeof options.gitApplyTimeoutMs === "number" && options.gitApplyTimeoutMs > 0
|
|
329
|
+
? options.gitApplyTimeoutMs
|
|
330
|
+
: 15_000;
|
|
331
|
+
this._getAst = options.getAst || null;
|
|
332
|
+
// Adaptive read-size governor. `null` = ungoverned (the default 64KB cap
|
|
333
|
+
// applies). When the runner arms the governor (context high-watermark), it
|
|
334
|
+
// calls setReadGovernor(maxBytes) with a lower cap (16KB). The governor
|
|
335
|
+
// only ever lowers the effective read cap — it never raises it above the
|
|
336
|
+
// caller's max_bytes. This is per-session state: the SandboxFsService is
|
|
337
|
+
// instantiated fresh per run() (see sandboxFsPlugin), so the governor
|
|
338
|
+
// cannot leak across tasks.
|
|
339
|
+
this.readGovernorMaxBytes = null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Arm (or disarm) the adaptive read-size governor for this session.
|
|
344
|
+
*
|
|
345
|
+
* @param {number|null} maxBytes The governed read cap in bytes. When a
|
|
346
|
+
* positive number, subsequent read_file calls are capped at this size
|
|
347
|
+
* (only ever lowering the effective cap, never raising it above the
|
|
348
|
+
* caller's max_bytes). When null/undefined, the governor is disarmed and
|
|
349
|
+
* the default 64KB cap applies.
|
|
350
|
+
*/
|
|
351
|
+
setReadGovernor(maxBytes) {
|
|
352
|
+
this.readGovernorMaxBytes =
|
|
353
|
+
typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Resolves and verifies that a target path is safely within allowed boundaries.
|
|
358
|
+
* @param {string} inputPath
|
|
359
|
+
* @returns {string} Normalized absolute path
|
|
360
|
+
*/
|
|
361
|
+
resolvePath(inputPath) {
|
|
362
|
+
if (!inputPath) return this.root;
|
|
363
|
+
if (typeof inputPath !== "string") {
|
|
364
|
+
throw new Error("InvalidPathError: Path must be a string");
|
|
365
|
+
}
|
|
366
|
+
if (inputPath.includes("\0")) {
|
|
367
|
+
throw new Error("NullByteError: Path contains prohibited null byte character");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let p = inputPath.trim();
|
|
371
|
+
const baseName = path.basename(p.replace(/\\/g, "/")).toUpperCase();
|
|
372
|
+
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/;
|
|
373
|
+
if (reserved.test(baseName)) {
|
|
374
|
+
throw new Error(`DeviceNameError: Prohibited access to Windows reserved device '${baseName}'`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (IS_WINDOWS) {
|
|
378
|
+
p = toWindowsPath(p);
|
|
379
|
+
if (!path.isAbsolute(p)) {
|
|
380
|
+
p = path.resolve(this.root, p);
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
p = toPosixWslPath(p);
|
|
384
|
+
if (!path.isAbsolute(p)) {
|
|
385
|
+
p = path.resolve(this.root, p);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const normalizedTarget = path.normalize(p);
|
|
389
|
+
const normalizedRoot = path.normalize(this.root);
|
|
390
|
+
|
|
391
|
+
// Canonicalize both sides of the containment comparison through the OS
|
|
392
|
+
// symlink/junction resolution layer. This ensures a junction-form target
|
|
393
|
+
// is compared against the real root in the same "real" path space,
|
|
394
|
+
// eliminating false PathEscapeError/SymlinkEscapeError. Real escapes
|
|
395
|
+
// (../outside, symlink-to-outside) are still caught because their
|
|
396
|
+
// realpath lands outside the real root.
|
|
397
|
+
const realTarget = canonicalizePath(normalizedTarget);
|
|
398
|
+
const realRoot = canonicalizePath(normalizedRoot);
|
|
399
|
+
const rel = path.relative(realRoot, realTarget);
|
|
400
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
401
|
+
throw new Error(`PathEscapeError: Access denied. Path '${inputPath}' escapes sandbox root '${this.root}'`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Symlink escape verification (both sides are now in real-path space)
|
|
405
|
+
this.verifySymlinkContainment(realTarget, realRoot);
|
|
406
|
+
|
|
407
|
+
// Return the ORIGINAL normalized path (not the canonical one) so that
|
|
408
|
+
// downstream path labels stay consistent with the path the caller passed
|
|
409
|
+
// in. The containment decision above was made in canonical space, which
|
|
410
|
+
// is what matters for security; the returned label is used for I/O and
|
|
411
|
+
// reporting (both the junction and real forms address the same file).
|
|
412
|
+
return normalizedTarget;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
verifySymlinkContainment(targetPath, rootPath) {
|
|
416
|
+
try {
|
|
417
|
+
if (fs.existsSync(targetPath)) {
|
|
418
|
+
const real = fs.realpathSync(targetPath);
|
|
419
|
+
const relReal = path.relative(rootPath, path.normalize(real));
|
|
420
|
+
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
421
|
+
throw new Error(`SymlinkEscapeError: Real path '${real}' escapes sandbox root '${rootPath}'`);
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
let parent = path.dirname(targetPath);
|
|
425
|
+
while (parent && parent !== path.dirname(parent)) {
|
|
426
|
+
if (fs.existsSync(parent)) {
|
|
427
|
+
const realParent = fs.realpathSync(parent);
|
|
428
|
+
const relReal = path.relative(rootPath, path.normalize(realParent));
|
|
429
|
+
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
430
|
+
throw new Error(`SymlinkEscapeError: Parent directory '${parent}' resolves to '${realParent}' escaping root '${rootPath}'`);
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
parent = path.dirname(parent);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch (err) {
|
|
438
|
+
if (err.message.startsWith("SymlinkEscapeError")) throw err;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Reads a slice of a file with line numbers.
|
|
444
|
+
*/
|
|
445
|
+
async readFile({ path: filePath, start_line = 1, end_line = null, max_bytes = 64_000 }) {
|
|
446
|
+
const resolved = this.resolvePath(filePath);
|
|
447
|
+
if (!fs.existsSync(resolved)) {
|
|
448
|
+
throw new Error(`File not found: ${filePath}`);
|
|
449
|
+
}
|
|
450
|
+
const stat = fs.statSync(resolved);
|
|
451
|
+
if (stat.isDirectory()) {
|
|
452
|
+
throw new Error(`Path is a directory, not a file: ${filePath}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Fail fast on binary files. A text-only model ingesting a PDF/PNG/etc.
|
|
456
|
+
// as UTF-8 produces mojibake. The binary is detected via the extension
|
|
457
|
+
// denylist fast-path and/or a magic-byte check (file-type) on the first
|
|
458
|
+
// KB, and an explicit BinaryFileError is thrown naming the file, the
|
|
459
|
+
// detected type, and the instruction to extract text via a
|
|
460
|
+
// format-appropriate tool instead. No silent skip, no truncated-content
|
|
461
|
+
// passthrough.
|
|
462
|
+
const binaryReason = await classifyBinary(resolved);
|
|
463
|
+
if (binaryReason) {
|
|
464
|
+
const detected = await detectBinaryType(resolved);
|
|
465
|
+
const detectedType = detected ? `${detected.mime} (.${detected.ext})` : "binary (extension denylist)";
|
|
466
|
+
throw new BinaryFileError(
|
|
467
|
+
`BinaryFileError: '${filePath}' is a binary file (${detectedType}); ` +
|
|
468
|
+
`reason: ${binaryReason}. A text-only model must not ingest binary content. ` +
|
|
469
|
+
`Do NOT read this file as text. To extract its text, use the bash tool with a ` +
|
|
470
|
+
`format-appropriate extractor (e.g. 'pdftotext file -' for PDF, 'strings file', 'exiftool file', 'unzip -l file') ` +
|
|
471
|
+
`and read the extracted text output instead.`,
|
|
472
|
+
{ file: resolved, detectedType, reason: binaryReason }
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const raw = fs.readFileSync(resolved, "utf8");
|
|
477
|
+
const allLines = raw.split("\n");
|
|
478
|
+
const totalLines = allLines.length;
|
|
479
|
+
|
|
480
|
+
const startIdx = Math.max(0, (start_line || 1) - 1);
|
|
481
|
+
const endIdx = end_line ? Math.min(totalLines, end_line) : Math.min(totalLines, startIdx + 800);
|
|
482
|
+
|
|
483
|
+
const slice = allLines.slice(startIdx, endIdx);
|
|
484
|
+
const numbered = slice
|
|
485
|
+
.map((line, idx) => `${startIdx + idx + 1}: ${line}`)
|
|
486
|
+
.join("\n");
|
|
487
|
+
|
|
488
|
+
// Adaptive read-size governor. When the runner has armed the governor
|
|
489
|
+
// (context high-watermark), the effective read cap is lowered to the
|
|
490
|
+
// governed size (16KB default) — but only ever below the caller's
|
|
491
|
+
// max_bytes (the governor never raises the cap). A read whose numbered
|
|
492
|
+
// content exceeds the governed cap is truncated to the cap and a
|
|
493
|
+
// suffix-scoped notice is appended to the content (never the prompt
|
|
494
|
+
// prefix, so KV-cache prefix stability is preserved).
|
|
495
|
+
const governorCap = this.readGovernorMaxBytes;
|
|
496
|
+
const effectiveMaxBytes =
|
|
497
|
+
governorCap != null ? Math.min(max_bytes, governorCap) : max_bytes;
|
|
498
|
+
const content = numbered.slice(0, effectiveMaxBytes);
|
|
499
|
+
const governedTruncation =
|
|
500
|
+
governorCap != null &&
|
|
501
|
+
effectiveMaxBytes === governorCap &&
|
|
502
|
+
numbered.length > effectiveMaxBytes;
|
|
503
|
+
|
|
504
|
+
const governedNotice = governedTruncation
|
|
505
|
+
? `\n[Output governed to ${Math.round(governorCap / 1024)}KB due to context pressure. Use narrow start_line/end_line offsets or ast_search.]`
|
|
506
|
+
: "";
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
path: resolved,
|
|
510
|
+
total_lines: totalLines,
|
|
511
|
+
showing_range: [startIdx + 1, endIdx],
|
|
512
|
+
content: content + governedNotice,
|
|
513
|
+
...(governedTruncation ? { governed: true, governedMaxBytes: governorCap } : {}),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Writes or overwrites a file.
|
|
519
|
+
*/
|
|
520
|
+
async writeFile({ path: filePath, content, overwrite = true }) {
|
|
521
|
+
if (!filePath || typeof filePath !== "string" || filePath.trim().length === 0) {
|
|
522
|
+
throw new Error("InvalidPathError: filePath parameter is required and must be a non-empty string");
|
|
523
|
+
}
|
|
524
|
+
const resolved = this.resolvePath(filePath);
|
|
525
|
+
if (resolved === path.normalize(this.root)) {
|
|
526
|
+
throw new Error(`InvalidPathError: Target path '${filePath}' resolves to the workspace root directory, not a file`);
|
|
527
|
+
}
|
|
528
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
529
|
+
throw new Error(`InvalidPathError: Target path '${filePath}' is an existing directory, cannot overwrite as file`);
|
|
530
|
+
}
|
|
531
|
+
if (fs.existsSync(resolved) && !overwrite) {
|
|
532
|
+
throw new Error(`File already exists and overwrite is false: ${filePath}`);
|
|
533
|
+
}
|
|
534
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
535
|
+
fs.writeFileSync(resolved, content, "utf8");
|
|
536
|
+
return { path: resolved, bytes_written: Buffer.byteLength(content, "utf8"), success: true };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Performs an exact or line-ending-normalized text replacement in a file.
|
|
541
|
+
*
|
|
542
|
+
* Guard rules:
|
|
543
|
+
* - 0 occurrences: explicit "target not found" error (no write).
|
|
544
|
+
* - 1 occurrence: proceed with replacement.
|
|
545
|
+
* - >1 occurrences without replace_all: refuse with count (no write).
|
|
546
|
+
* - >1 occurrences with replace_all: replace all.
|
|
547
|
+
*
|
|
548
|
+
* `replacement_content` is validated up front. It must be a string. An
|
|
549
|
+
* explicit empty string `""` is allowed (it deletes the matched region).
|
|
550
|
+
* `undefined`/`null`/non-string values are rejected with an explicit
|
|
551
|
+
* `InvalidReplacementError` so they can never be silently coerced to the
|
|
552
|
+
* literal string `"undefined"` and written to disk.
|
|
553
|
+
*
|
|
554
|
+
* Line-ending auto-normalization is localized to the matched region, not a
|
|
555
|
+
* whole-file boolean. The style of the region is inferred from which form
|
|
556
|
+
* of the target actually matches the file (exact / CRLF / LF). The
|
|
557
|
+
* replacement is normalized to that local style, so editing one region of a
|
|
558
|
+
* mixed CRLF/LF file preserves that region's endings without cross-
|
|
559
|
+
* pollinating the rest of the file.
|
|
560
|
+
*
|
|
561
|
+
* A target whose line endings differ from the file's is auto-normalized to
|
|
562
|
+
* the local style and the edit succeeds silently (no
|
|
563
|
+
* `LineEndingMismatchError`). Callers that want an explicit mismatch signal
|
|
564
|
+
* can pre-check the file's line endings themselves.
|
|
565
|
+
*/
|
|
566
|
+
async editFile({ path: filePath, target_content, replacement_content, replace_all = false }) {
|
|
567
|
+
if (!target_content || typeof target_content !== "string" || target_content.length === 0) {
|
|
568
|
+
throw new Error("target_content cannot be empty");
|
|
569
|
+
}
|
|
570
|
+
// Validate replacement_content before any I/O. Must be a string; explicit
|
|
571
|
+
// "" is allowed (deletion). undefined/null/non-string are rejected so they
|
|
572
|
+
// can never coerce to the literal "undefined" and corrupt the file.
|
|
573
|
+
if (typeof replacement_content !== "string") {
|
|
574
|
+
throw new Error(
|
|
575
|
+
`InvalidReplacementError: replacement_content must be a string (got ${
|
|
576
|
+
replacement_content === undefined ? "undefined" : typeof replacement_content
|
|
577
|
+
}). Pass an explicit empty string "" to delete the matched region. No write performed.`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const resolved = this.resolvePath(filePath);
|
|
581
|
+
if (!fs.existsSync(resolved)) {
|
|
582
|
+
throw new Error(`File not found for edit: ${filePath}`);
|
|
583
|
+
}
|
|
584
|
+
const original = fs.readFileSync(resolved, "utf8");
|
|
585
|
+
|
|
586
|
+
// Determine the local line-ending style of the matched region by testing
|
|
587
|
+
// which form of the target actually matches the file.
|
|
588
|
+
// 1. exact match -> the target's own endings are the local style
|
|
589
|
+
// 2. CRLF-normalized match -> region is CRLF
|
|
590
|
+
// 3. LF-normalized match -> region is LF
|
|
591
|
+
// 4. none -> target not found
|
|
592
|
+
const crlfTarget = target_content.replace(/\r?\n/g, "\r\n");
|
|
593
|
+
const lfTarget = target_content.replace(/\r\n/g, "\n");
|
|
594
|
+
const exactCount = original.split(target_content).length - 1;
|
|
595
|
+
const crlfCount = original.split(crlfTarget).length - 1;
|
|
596
|
+
const lfCount = original.split(lfTarget).length - 1;
|
|
597
|
+
|
|
598
|
+
let effectiveTarget;
|
|
599
|
+
let localStyle;
|
|
600
|
+
if (exactCount > 0) {
|
|
601
|
+
effectiveTarget = target_content;
|
|
602
|
+
localStyle = lineEndingStyle(target_content);
|
|
603
|
+
} else if (crlfCount > 0) {
|
|
604
|
+
effectiveTarget = crlfTarget;
|
|
605
|
+
localStyle = "crlf";
|
|
606
|
+
} else if (lfCount > 0) {
|
|
607
|
+
effectiveTarget = lfTarget;
|
|
608
|
+
localStyle = "lf";
|
|
609
|
+
} else {
|
|
610
|
+
throw new Error(`Target content not found in file: ${filePath}. No write performed.`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const occurrences = original.split(effectiveTarget).length - 1;
|
|
614
|
+
if (occurrences > 1 && !replace_all) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
`AmbiguousTargetError: target_content found ${occurrences} times in file: ${filePath}. ` +
|
|
617
|
+
`Provide a longer unique target (include surrounding lines) or pass replace_all: true. No write performed.`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Normalize the replacement to the local style of the matched region. For
|
|
622
|
+
// a uniform file this is identical to a whole-file normalization; for a
|
|
623
|
+
// mixed file it preserves the region's own endings without cross-
|
|
624
|
+
// pollinating the rest of the file.
|
|
625
|
+
const effectiveReplacement = normalizeLineEndings(replacement_content, localStyle);
|
|
626
|
+
|
|
627
|
+
const updated = replace_all
|
|
628
|
+
? original.replaceAll(effectiveTarget, effectiveReplacement)
|
|
629
|
+
: original.replace(effectiveTarget, effectiveReplacement);
|
|
630
|
+
|
|
631
|
+
// In-memory AST & syntax validation gate before writing to disk
|
|
632
|
+
let syntaxVerified = false;
|
|
633
|
+
const ast = this._getAst ? this._getAst() : await getSharedAstService();
|
|
634
|
+
if (ast && typeof ast.inferLanguageByExtension === "function" && typeof ast.validateSyntax === "function") {
|
|
635
|
+
const lang = ast.inferLanguageByExtension(resolved);
|
|
636
|
+
if (lang) {
|
|
637
|
+
const check = ast.validateSyntax(resolved, updated, lang);
|
|
638
|
+
if (check.checked) {
|
|
639
|
+
if (!check.valid) {
|
|
640
|
+
throw new SyntaxValidationError(
|
|
641
|
+
resolved,
|
|
642
|
+
check.error || `Syntax validation check failed for '${lang}'`
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
syntaxVerified = true;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
fs.writeFileSync(resolved, updated, "utf8");
|
|
651
|
+
return {
|
|
652
|
+
path: resolved,
|
|
653
|
+
occurrences_replaced: replace_all ? occurrences : 1,
|
|
654
|
+
success: true,
|
|
655
|
+
syntax_verified: syntaxVerified,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Lists directory contents while strictly filtering out ignored directories.
|
|
661
|
+
*/
|
|
662
|
+
async listDir({ path: dirPath = ".", max_depth = 2 }) {
|
|
663
|
+
const resolved = this.resolvePath(dirPath);
|
|
664
|
+
if (!fs.existsSync(resolved)) {
|
|
665
|
+
throw new Error(`Directory not found: ${dirPath}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let listingPartial = false;
|
|
669
|
+
const walk = (currentDir, depth) => {
|
|
670
|
+
if (depth > max_depth) return [];
|
|
671
|
+
const entries = [];
|
|
672
|
+
let files = [];
|
|
673
|
+
try {
|
|
674
|
+
files = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
675
|
+
} catch (err) {
|
|
676
|
+
process.stderr.write(`[SandboxFs] Unreadable directory ${currentDir}: ${err.message}\n`);
|
|
677
|
+
listingPartial = true;
|
|
678
|
+
return [];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
for (const f of files) {
|
|
682
|
+
if (this.ignoredDirs.has(f.name)) continue;
|
|
683
|
+
const full = path.join(currentDir, f.name);
|
|
684
|
+
const rel = path.relative(resolved, full);
|
|
685
|
+
if (f.isDirectory()) {
|
|
686
|
+
entries.push({ name: f.name, path: rel, type: "dir" });
|
|
687
|
+
entries.push(...walk(full, depth + 1));
|
|
688
|
+
} else {
|
|
689
|
+
entries.push({ name: f.name, path: rel, type: "file" });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return entries;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const items = walk(resolved, 1);
|
|
696
|
+
return { path: resolved, total_items: items.length, items, ...(listingPartial ? { partial: true } : {}) };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Pre-validates the file paths referenced by a unified diff patch so the
|
|
701
|
+
* sandbox boundary is explicit rather than solely delegated to `git apply`
|
|
702
|
+
* (whose handling of `../` and absolute paths is version- and
|
|
703
|
+
* config-dependent).
|
|
704
|
+
*
|
|
705
|
+
* The target paths are extracted from each file section's `--- a/<p>` and
|
|
706
|
+
* `+++ b/<p>` headers. For each path:
|
|
707
|
+
* - `/dev/null` is allowed (it denotes a newly-created or deleted file).
|
|
708
|
+
* - a leading `a/` or `b/` side marker (the standard unified-diff prefix)
|
|
709
|
+
* is stripped before validation.
|
|
710
|
+
* - a null byte is rejected.
|
|
711
|
+
* - an absolute path is rejected (the only legitimate absolute path,
|
|
712
|
+
* `/dev/null`, is handled above).
|
|
713
|
+
* - a relative path is resolved against the patch base directory and must
|
|
714
|
+
* stay within the sandbox root; a path that escapes the root (e.g.
|
|
715
|
+
* `../outside`) is rejected.
|
|
716
|
+
*
|
|
717
|
+
* This runs BEFORE `git` is spawned, so a malicious or malformed path is
|
|
718
|
+
* rejected cheaply and deterministically.
|
|
719
|
+
*
|
|
720
|
+
* @param {string} patch
|
|
721
|
+
* @param {string} baseDir The resolved base directory (git apply cwd).
|
|
722
|
+
* @returns {string[]} the validated relative paths (for reporting).
|
|
723
|
+
*/
|
|
724
|
+
_validatePatchPaths(patch, baseDir) {
|
|
725
|
+
const root = this.root;
|
|
726
|
+
const seen = new Set();
|
|
727
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
728
|
+
let target = null;
|
|
729
|
+
let m = line.match(/^---\s+(.+?)\s*$/);
|
|
730
|
+
if (m) target = m[1];
|
|
731
|
+
else {
|
|
732
|
+
m = line.match(/^\+\+\+\s+(.+?)\s*$/);
|
|
733
|
+
if (m) target = m[1];
|
|
734
|
+
}
|
|
735
|
+
if (target === null) continue;
|
|
736
|
+
// A trailing tab-separated timestamp (e.g. "--- a/path\t2024-01-01") is
|
|
737
|
+
// not part of the path.
|
|
738
|
+
const tabIdx = target.indexOf("\t");
|
|
739
|
+
if (tabIdx !== -1) target = target.slice(0, tabIdx);
|
|
740
|
+
// /dev/null denotes a new (---) or deleted (+++) file — always allowed.
|
|
741
|
+
if (target === "/dev/null") continue;
|
|
742
|
+
let p = target;
|
|
743
|
+
if (p.startsWith("a/") || p.startsWith("b/")) p = p.slice(2);
|
|
744
|
+
if (p.includes("\0")) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`PatchPathError: patch path '${target}' contains a prohibited null byte. No files were modified.`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
if (path.isAbsolute(p)) {
|
|
750
|
+
throw new Error(
|
|
751
|
+
`PatchPathError: patch references an absolute path '${target}', which is not allowed. No files were modified.`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const resolved = path.resolve(baseDir, p);
|
|
755
|
+
const rel = path.relative(root, resolved);
|
|
756
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
757
|
+
throw new Error(
|
|
758
|
+
`PatchPathError: patch path '${target}' resolves outside the sandbox root. No files were modified.`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
seen.add(p);
|
|
762
|
+
}
|
|
763
|
+
return [...seen];
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Applies a standard unified diff patch using `git apply`.
|
|
768
|
+
*
|
|
769
|
+
* Size cap: the `patch` payload is measured in bytes and rejected with an
|
|
770
|
+
* explicit `PatchTooLargeError` before any `git` subprocess is spawned if it
|
|
771
|
+
* exceeds `MAX_PATCH_SIZE` (2 MB). This bounds memory and the child-process
|
|
772
|
+
* input pipe for pathological payloads.
|
|
773
|
+
*
|
|
774
|
+
* Distinct timeout signal: if the `git apply` subprocess is killed by the
|
|
775
|
+
* timeout (or otherwise terminated by a signal), a distinct
|
|
776
|
+
* `GitApplyTimeoutError` is thrown instead of the generic `GitApplyError`,
|
|
777
|
+
* so a hang/timeout is distinguishable from a normal "patch does not apply"
|
|
778
|
+
* failure. Node reports a timeout as `err.code === "ETIMEDOUT"` and
|
|
779
|
+
* `err.signal === "SIGTERM"` (and `err.killed === true` on some platforms);
|
|
780
|
+
* all three are checked.
|
|
781
|
+
*
|
|
782
|
+
* Faithful whitespace: `--whitespace=fix` is not used. That flag silently
|
|
783
|
+
* rewrites whitespace in the applied content (e.g. it strips trailing spaces
|
|
784
|
+
* and emits "line applied after fixing whitespace errors"), so the on-disk
|
|
785
|
+
* result could differ from the literal patch. The default `git apply`
|
|
786
|
+
* behavior writes the patch bytes faithfully (it only warns about
|
|
787
|
+
* whitespace, it does not modify it).
|
|
788
|
+
*
|
|
789
|
+
* Line-ending behavior is context-dependent:
|
|
790
|
+
* (a) In a git repository whose `.gitattributes` pins
|
|
791
|
+
* `* text=auto eol=lf` (as this workspace does), `git apply`
|
|
792
|
+
* normalizes the applied content to LF on write, so a CRLF file is
|
|
793
|
+
* converted to LF. This is a property of the repo's `.gitattributes`,
|
|
794
|
+
* not of this method.
|
|
795
|
+
* (b) In a non-git directory, `git apply` still works (it does not require
|
|
796
|
+
* a repository) but no `.gitattributes` normalization applies, so the
|
|
797
|
+
* patch bytes are written verbatim (line endings preserved as given).
|
|
798
|
+
*
|
|
799
|
+
* The base directory (`dirPath`) is validated against the sandbox root by
|
|
800
|
+
* `resolvePath()`, and every file path inside the patch is explicitly
|
|
801
|
+
* pre-validated by `_validatePatchPaths()` before `git` is spawned (rejecting
|
|
802
|
+
* `../` traversal, absolute paths, and null bytes). This makes the boundary
|
|
803
|
+
* deterministic rather than relying on `git apply`'s version/config-specific
|
|
804
|
+
* handling; `git apply` additionally refuses to write through symlinks that
|
|
805
|
+
* escape the working tree. The adversarial regression tests in
|
|
806
|
+
* `tests/apply_patch.test.js` pin this boundary.
|
|
807
|
+
*
|
|
808
|
+
* @param {object} args
|
|
809
|
+
* @param {string} args.patch Standard unified diff patch content.
|
|
810
|
+
* @param {string} [args.dirPath="."] Target base directory (sandbox-relative).
|
|
811
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
812
|
+
*/
|
|
813
|
+
async applyPatch({ patch, dirPath = "." }) {
|
|
814
|
+
if (!patch || typeof patch !== "string" || patch.trim().length === 0) {
|
|
815
|
+
throw new Error("patch cannot be empty");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Enforce the size cap before spawning git.
|
|
819
|
+
const patchBytes = Buffer.byteLength(patch, "utf8");
|
|
820
|
+
if (patchBytes > MAX_PATCH_SIZE) {
|
|
821
|
+
throw new PatchTooLargeError(
|
|
822
|
+
`PatchTooLargeError: patch is ${patchBytes} bytes, exceeding the ${MAX_PATCH_SIZE} byte limit. ` +
|
|
823
|
+
`No git subprocess was spawned and no files were modified.`,
|
|
824
|
+
{ size: patchBytes, limit: MAX_PATCH_SIZE }
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const resolved = this.resolvePath(dirPath);
|
|
829
|
+
|
|
830
|
+
// Explicitly validate every file path in the patch against the sandbox
|
|
831
|
+
// root before invoking git, so the boundary does not depend on git's
|
|
832
|
+
// version/config-specific handling of `../` and absolute paths.
|
|
833
|
+
this._validatePatchPaths(patch, resolved);
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
// No --whitespace=fix — write the patch bytes faithfully.
|
|
837
|
+
execFileSync("git", ["apply", "--unidiff-zero", "-"], {
|
|
838
|
+
cwd: resolved,
|
|
839
|
+
input: patch,
|
|
840
|
+
encoding: "utf8",
|
|
841
|
+
timeout: this.gitApplyTimeoutMs,
|
|
842
|
+
windowsHide: true,
|
|
843
|
+
});
|
|
844
|
+
return { success: true, message: "Patch applied cleanly" };
|
|
845
|
+
} catch (err) {
|
|
846
|
+
// A timeout / signal kill is a distinct signal.
|
|
847
|
+
const isTimeout =
|
|
848
|
+
err &&
|
|
849
|
+
(err.killed === true || err.code === "ETIMEDOUT" || err.signal === "SIGTERM");
|
|
850
|
+
if (isTimeout) {
|
|
851
|
+
const stderr = err && err.stderr ? String(err.stderr).trim() : "";
|
|
852
|
+
throw new GitApplyTimeoutError(
|
|
853
|
+
`GitApplyTimeoutError: git apply was terminated by a timeout/signal ` +
|
|
854
|
+
`(signal=${err && err.signal ? err.signal : "unknown"}). ` +
|
|
855
|
+
`The patch was NOT applied (git apply is atomic). ${stderr}`,
|
|
856
|
+
{ signal: err && err.signal ? err.signal : null, stderr }
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
const msg = err.stderr ? err.stderr.toString().trim() : (err.message || String(err));
|
|
860
|
+
throw new Error(`GitApplyError: Failed to apply patch: ${msg}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Determines whether dirPath is inside a git repository, distinguishing a
|
|
866
|
+
* non-git directory from a corrupted git repository.
|
|
867
|
+
*
|
|
868
|
+
* Verifies the presence of a .git entry in dirPath or ancestor directories
|
|
869
|
+
* to ensure corrupted repositories fail fast while non-git directories fall
|
|
870
|
+
* back to filesystem search.
|
|
871
|
+
*
|
|
872
|
+
* @param {string} dirPath - Directory path to inspect.
|
|
873
|
+
* @returns {Promise<boolean>} True if inside a git repository.
|
|
874
|
+
*/
|
|
875
|
+
async _isInsideGitRepo(dirPath) {
|
|
876
|
+
try {
|
|
877
|
+
const { code } = await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
878
|
+
cwd: dirPath,
|
|
879
|
+
timeout: 5_000,
|
|
880
|
+
});
|
|
881
|
+
if (code === 0) return true;
|
|
882
|
+
} catch {
|
|
883
|
+
// rev-parse failed (non-git dir OR corrupted repo) — fall through to
|
|
884
|
+
// the .git-presence check below.
|
|
885
|
+
}
|
|
886
|
+
// Walk up from dirPath to the filesystem root looking for a `.git`
|
|
887
|
+
// entry. A real or corrupted repository has one; a non-git directory
|
|
888
|
+
// does not.
|
|
889
|
+
let dir = dirPath;
|
|
890
|
+
for (;;) {
|
|
891
|
+
try {
|
|
892
|
+
if (fs.existsSync(path.join(dir, ".git"))) return true;
|
|
893
|
+
} catch {
|
|
894
|
+
// ignore stat errors and keep climbing
|
|
895
|
+
}
|
|
896
|
+
const parent = path.dirname(dir);
|
|
897
|
+
if (parent === dir) break; // reached the filesystem root
|
|
898
|
+
dir = parent;
|
|
899
|
+
}
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Formats and truncates a single grep match line to bound output size.
|
|
905
|
+
*
|
|
906
|
+
* Query-centered windowing: if the match query occurs late in a long line, centers
|
|
907
|
+
* a preview window around the match so the matched token is not truncated away.
|
|
908
|
+
*
|
|
909
|
+
* @param {string} rawLine - e.g. "path/to/file.js:42:code content"
|
|
910
|
+
* @param {string} query - The query string to anchor the preview window
|
|
911
|
+
* @param {number} [maxLineChars=300] - Maximum line character cap
|
|
912
|
+
* @returns {string} Truncated, bounded line preview
|
|
913
|
+
*/
|
|
914
|
+
_formatMatchLine(rawLine, query, maxLineChars = 300) {
|
|
915
|
+
if (!rawLine || rawLine.length <= maxLineChars) {
|
|
916
|
+
return rawLine;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const matchPrefix = rawLine.match(/^([^:\r\n]+:\d+:)(.*)$/s);
|
|
920
|
+
if (!matchPrefix) {
|
|
921
|
+
const qIdx = rawLine.indexOf(query);
|
|
922
|
+
if (qIdx === -1 || qIdx <= 120) {
|
|
923
|
+
return `${rawLine.slice(0, maxLineChars).trim()} ... [truncated line: ${rawLine.length} chars]`;
|
|
924
|
+
}
|
|
925
|
+
const start = Math.max(0, qIdx - 80);
|
|
926
|
+
const end = Math.min(rawLine.length, qIdx + query.length + 120);
|
|
927
|
+
return `... ${rawLine.slice(start, end).trim()} ... [truncated line: ${rawLine.length} chars, match at col ${qIdx + 1}]`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const prefix = matchPrefix[1];
|
|
931
|
+
const content = matchPrefix[2];
|
|
932
|
+
if (content.length <= maxLineChars) {
|
|
933
|
+
return rawLine;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const qIdx = content.indexOf(query);
|
|
937
|
+
if (qIdx === -1 || qIdx <= 120) {
|
|
938
|
+
const preview = content.slice(0, maxLineChars);
|
|
939
|
+
return `${prefix} ${preview.trim()} ... [truncated line: ${content.length} chars]`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const start = Math.max(0, qIdx - 80);
|
|
943
|
+
const end = Math.min(content.length, qIdx + query.length + 120);
|
|
944
|
+
const preview = content.slice(start, end);
|
|
945
|
+
return `${prefix} ... ${preview.trim()} ... [truncated line: ${content.length} chars, match at col ${qIdx + 1}]`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Fast indexed search using git grep (respecting .gitignore) or a safe
|
|
950
|
+
* fallback file search.
|
|
951
|
+
*
|
|
952
|
+
* The query is matched as a literal string (`-F` / `--fixed-strings`),
|
|
953
|
+
* aligning the implementation with the documented "Literal string to
|
|
954
|
+
* search for" contract.
|
|
955
|
+
*
|
|
956
|
+
* A fatal `git grep` error (any non-zero exit code other than 1) inside a
|
|
957
|
+
* git repository fails fast with a `GitGrepError` instead of falling
|
|
958
|
+
* through to the manual walk. Exit code 1 ("no matches") is a valid empty
|
|
959
|
+
* result. In a non-git directory the manual walk is used, but it
|
|
960
|
+
* explicitly skips `.env` / `.env.*` files to prevent secret ingestion.
|
|
961
|
+
*
|
|
962
|
+
* `max_results` caps the total number of matches returned, consistently
|
|
963
|
+
* across both the git-grep and fallback paths.
|
|
964
|
+
*
|
|
965
|
+
* Line length and aggregate payload are strictly bounded:
|
|
966
|
+
* - Each match line exceeding 300 chars is query-centered and truncated.
|
|
967
|
+
* - Total match payload is capped at 32 KB across all matches.
|
|
968
|
+
* - Both `path` and `dirPath` are accepted and passed as git pathspecs.
|
|
969
|
+
*
|
|
970
|
+
* @param {object} args
|
|
971
|
+
* @param {string} args.query Literal string to search for.
|
|
972
|
+
* @param {string} [args.path] Root search directory or target file path.
|
|
973
|
+
* @param {string} [args.dirPath] Alias for path.
|
|
974
|
+
* @param {number} [args.max_results=50] Maximum number of matches returned.
|
|
975
|
+
* @returns {Promise<{query: string, count: number, matches: string[], skipped?: string[], truncated: boolean}>}
|
|
976
|
+
*/
|
|
977
|
+
async searchCode({ query, path: targetPath, dirPath, max_results = 50 }) {
|
|
978
|
+
const rawTarget = targetPath || dirPath || ".";
|
|
979
|
+
const resolved = this.resolvePath(rawTarget);
|
|
980
|
+
const cap = Math.max(0, max_results | 0);
|
|
981
|
+
const MAX_TOTAL_BYTES = 32 * 1024; // 32KB aggregate payload cap
|
|
982
|
+
|
|
983
|
+
let isFile = false;
|
|
984
|
+
try {
|
|
985
|
+
isFile = fs.existsSync(resolved) && fs.statSync(resolved).isFile();
|
|
986
|
+
} catch {}
|
|
987
|
+
|
|
988
|
+
const searchDir = isFile ? path.dirname(resolved) : resolved;
|
|
989
|
+
let inRepo = false;
|
|
990
|
+
try {
|
|
991
|
+
inRepo = await this._isInsideGitRepo(searchDir);
|
|
992
|
+
} catch {
|
|
993
|
+
inRepo = false;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (inRepo) {
|
|
997
|
+
try {
|
|
998
|
+
const gitArgs = ["grep", "-n", "-I", "-F", "--untracked", "-e", query];
|
|
999
|
+
const relSpec = path.relative(this.root, resolved).replace(/\\/g, "/");
|
|
1000
|
+
if (relSpec && relSpec !== "." && relSpec !== "") {
|
|
1001
|
+
gitArgs.push("--", relSpec);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const { stdout } = await execFileAsync("git", gitArgs, {
|
|
1005
|
+
cwd: this.root,
|
|
1006
|
+
timeout: 10_000,
|
|
1007
|
+
});
|
|
1008
|
+
const rawLines = stdout.trim().split("\n").filter(Boolean);
|
|
1009
|
+
const matches = [];
|
|
1010
|
+
let totalBytes = 0;
|
|
1011
|
+
let hitPayloadCap = false;
|
|
1012
|
+
|
|
1013
|
+
for (const rawLine of rawLines) {
|
|
1014
|
+
if (matches.length >= cap) break;
|
|
1015
|
+
const formatted = this._formatMatchLine(rawLine, query, 300);
|
|
1016
|
+
const lineBytes = Buffer.byteLength(formatted, "utf8");
|
|
1017
|
+
if (totalBytes + lineBytes > MAX_TOTAL_BYTES) {
|
|
1018
|
+
matches.push(`... [Remaining matches truncated: reached 32KB result payload limit]`);
|
|
1019
|
+
hitPayloadCap = true;
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
matches.push(formatted);
|
|
1023
|
+
totalBytes += lineBytes;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
query,
|
|
1028
|
+
count: matches.length,
|
|
1029
|
+
matches,
|
|
1030
|
+
truncated: hitPayloadCap || rawLines.length > matches.length,
|
|
1031
|
+
};
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
// git grep exit code 1 means "no matches found", NOT an execution error.
|
|
1034
|
+
if (err && err.code === 1) {
|
|
1035
|
+
return {
|
|
1036
|
+
query,
|
|
1037
|
+
count: 0,
|
|
1038
|
+
matches: [],
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Fatal git error in repo
|
|
1043
|
+
const stderr = err && err.stderr ? String(err.stderr).trim() : "";
|
|
1044
|
+
throw new GitGrepError(
|
|
1045
|
+
`GitGrepError: git grep failed with exit code ${err && err.code} in a git repository: ${stderr || err.message}`,
|
|
1046
|
+
{ code: err && err.code, stderr }
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Fallback: not a git repository. Search files avoiding ignored
|
|
1052
|
+
// directories and `.env` / `.env.*` files (secret-leak prevention). The
|
|
1053
|
+
// total match count is capped at `max_results`.
|
|
1054
|
+
//
|
|
1055
|
+
// Binary files are skipped (reported in `skipped` as "skipped-binary")
|
|
1056
|
+
// rather than read as UTF-8.
|
|
1057
|
+
const matches = [];
|
|
1058
|
+
const skipped = [];
|
|
1059
|
+
let totalBytes = 0;
|
|
1060
|
+
let hitPayloadCap = false;
|
|
1061
|
+
|
|
1062
|
+
const walkSearch = async (cur) => {
|
|
1063
|
+
if (matches.length >= cap || hitPayloadCap) return;
|
|
1064
|
+
let files;
|
|
1065
|
+
try {
|
|
1066
|
+
const stat = fs.statSync(cur);
|
|
1067
|
+
if (stat.isFile()) {
|
|
1068
|
+
files = [{ name: path.basename(cur), isDirectory: () => false, isFile: () => true }];
|
|
1069
|
+
cur = path.dirname(cur);
|
|
1070
|
+
} else {
|
|
1071
|
+
files = fs.readdirSync(cur, { withFileTypes: true });
|
|
1072
|
+
}
|
|
1073
|
+
} catch {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
for (const f of files) {
|
|
1077
|
+
if (matches.length >= cap || hitPayloadCap) break;
|
|
1078
|
+
if (this.ignoredDirs.has(f.name)) continue;
|
|
1079
|
+
if (isEnvFile(f.name)) continue; // never ingest .env / .env.*
|
|
1080
|
+
const full = path.join(cur, f.name);
|
|
1081
|
+
if (f.isDirectory()) {
|
|
1082
|
+
await walkSearch(full);
|
|
1083
|
+
} else if (f.isFile()) {
|
|
1084
|
+
let size = f.size;
|
|
1085
|
+
if (size === undefined) {
|
|
1086
|
+
try {
|
|
1087
|
+
size = fs.statSync(full).size;
|
|
1088
|
+
} catch {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (size < 500_000) {
|
|
1093
|
+
if (isBinaryExtension(f.name)) {
|
|
1094
|
+
skipped.push(`${path.relative(this.root, full).replace(/\\/g, "/")} (skipped-binary: extension '${f.name}')`);
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
let buf;
|
|
1098
|
+
try {
|
|
1099
|
+
buf = fs.readFileSync(full);
|
|
1100
|
+
} catch {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
const head = buf.subarray(0, reasonableDetectionSizeInBytes);
|
|
1104
|
+
const detected = await fileTypeFromBuffer(head);
|
|
1105
|
+
if (detected) {
|
|
1106
|
+
skipped.push(`${path.relative(this.root, full).replace(/\\/g, "/")} (skipped-binary: ${detected.mime} .${detected.ext})`);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
const text = buf.toString("utf8");
|
|
1110
|
+
if (text.includes(query)) {
|
|
1111
|
+
const lines = text.split("\n");
|
|
1112
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
1113
|
+
const l = lines[idx];
|
|
1114
|
+
if (l.includes(query)) {
|
|
1115
|
+
if (matches.length >= cap) break;
|
|
1116
|
+
const relPath = path.relative(this.root, full).replace(/\\/g, "/");
|
|
1117
|
+
const formatted = this._formatMatchLine(`${relPath}:${idx + 1}:${l}`, query, 300);
|
|
1118
|
+
const lineBytes = Buffer.byteLength(formatted, "utf8");
|
|
1119
|
+
if (totalBytes + lineBytes > MAX_TOTAL_BYTES) {
|
|
1120
|
+
matches.push(`... [Remaining matches truncated: reached 32KB result payload limit]`);
|
|
1121
|
+
hitPayloadCap = true;
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
matches.push(formatted);
|
|
1125
|
+
totalBytes += lineBytes;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
await walkSearch(resolved);
|
|
1134
|
+
return {
|
|
1135
|
+
query,
|
|
1136
|
+
count: matches.length,
|
|
1137
|
+
matches,
|
|
1138
|
+
skipped,
|
|
1139
|
+
truncated: hitPayloadCap,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Castor Plugin to mount SandboxFsService and its tools into Context.
|
|
1146
|
+
*/
|
|
1147
|
+
export function sandboxFsPlugin(ctx, options = {}) {
|
|
1148
|
+
const fsService = new SandboxFsService({
|
|
1149
|
+
...options,
|
|
1150
|
+
getAst: () => ctx.get("ast"),
|
|
1151
|
+
});
|
|
1152
|
+
ctx.provide("fs", fsService);
|
|
1153
|
+
|
|
1154
|
+
ctx.registerTool("read_file", {
|
|
1155
|
+
description: "Read a slice of a text file with line numbers (safe, bounded). Always use this tool instead of shell commands (cat, head, tail, sed) in bash.",
|
|
1156
|
+
parameters: {
|
|
1157
|
+
type: "object",
|
|
1158
|
+
properties: {
|
|
1159
|
+
path: { type: "string", description: "File path" },
|
|
1160
|
+
start_line: { type: "integer", description: "1-indexed starting line", default: 1 },
|
|
1161
|
+
end_line: { type: "integer", description: "1-indexed ending line (optional)" },
|
|
1162
|
+
},
|
|
1163
|
+
required: ["path"],
|
|
1164
|
+
},
|
|
1165
|
+
execute: (args) => fsService.readFile(args),
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
ctx.registerTool("write_file", {
|
|
1169
|
+
description: "Create or overwrite a file with full content",
|
|
1170
|
+
parameters: {
|
|
1171
|
+
type: "object",
|
|
1172
|
+
properties: {
|
|
1173
|
+
path: { type: "string", description: "File path" },
|
|
1174
|
+
content: { type: "string", description: "File content to write" },
|
|
1175
|
+
overwrite: { type: "boolean", description: "Whether to overwrite existing file", default: true },
|
|
1176
|
+
},
|
|
1177
|
+
required: ["path", "content"],
|
|
1178
|
+
},
|
|
1179
|
+
execute: (args) => fsService.writeFile(args),
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
ctx.registerTool("edit_file", {
|
|
1183
|
+
description:
|
|
1184
|
+
"Perform exact text search-and-replace in a file with transparent in-memory syntax validation. Always use this tool instead of sed/awk in bash. " +
|
|
1185
|
+
"target_content must occur exactly once unless replace_all is true; ambiguous edits are refused. " +
|
|
1186
|
+
"If the edit introduces syntax errors (JS, TS, Python, JSON, LaTeX, BibTeX), it is automatically rejected before disk write.",
|
|
1187
|
+
parameters: {
|
|
1188
|
+
type: "object",
|
|
1189
|
+
properties: {
|
|
1190
|
+
path: { type: "string", description: "File path" },
|
|
1191
|
+
target_content: { type: "string", description: "Exact character sequence to replace (must be unique unless replace_all is true)" },
|
|
1192
|
+
replacement_content: { type: "string", description: "Replacement content" },
|
|
1193
|
+
replace_all: { type: "boolean", description: "If true, replace every occurrence of target_content. If false (default), target must occur exactly once.", default: false },
|
|
1194
|
+
},
|
|
1195
|
+
required: ["path", "target_content", "replacement_content"],
|
|
1196
|
+
},
|
|
1197
|
+
execute: (args) => fsService.editFile(args),
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
ctx.registerTool("apply_patch", {
|
|
1201
|
+
description: "Apply a standard unified diff patch atomically using git apply (--unidiff-zero)",
|
|
1202
|
+
parameters: {
|
|
1203
|
+
type: "object",
|
|
1204
|
+
properties: {
|
|
1205
|
+
patch: { type: "string", description: "Standard unified diff patch content" },
|
|
1206
|
+
path: { type: "string", description: "Target base directory", default: "." },
|
|
1207
|
+
},
|
|
1208
|
+
required: ["patch"],
|
|
1209
|
+
},
|
|
1210
|
+
execute: (args) => fsService.applyPatch(args),
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
ctx.registerTool("list_dir", {
|
|
1214
|
+
description: "List directory contents while auto-ignoring .venv, node_modules, and .git. Always use this tool instead of shell commands (ls, find) in bash.",
|
|
1215
|
+
parameters: {
|
|
1216
|
+
type: "object",
|
|
1217
|
+
properties: {
|
|
1218
|
+
path: { type: "string", description: "Directory path", default: "." },
|
|
1219
|
+
max_depth: { type: "integer", description: "Recursion depth", default: 2 },
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
execute: (args) => fsService.listDir(args),
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
ctx.registerTool("search_code", {
|
|
1226
|
+
description: "Fast code and pattern search across the codebase avoiding ignored directories. Always use this tool instead of shell commands (grep, rg) in bash.",
|
|
1227
|
+
parameters: {
|
|
1228
|
+
type: "object",
|
|
1229
|
+
properties: {
|
|
1230
|
+
query: { type: "string", description: "Literal string to search for" },
|
|
1231
|
+
path: { type: "string", description: "Root search directory or target file path", default: "." },
|
|
1232
|
+
dirPath: { type: "string", description: "Alias for path", default: "." },
|
|
1233
|
+
},
|
|
1234
|
+
required: ["query"],
|
|
1235
|
+
},
|
|
1236
|
+
execute: (args) => fsService.searchCode(args),
|
|
1237
|
+
});
|
|
1238
|
+
}
|