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.
Files changed (42) hide show
  1. package/README.md +487 -0
  2. package/bin/castor.js +706 -0
  3. package/index.js +206 -0
  4. package/package.json +97 -0
  5. package/skills/canary-test-staging/SKILL.md +24 -0
  6. package/skills/evo-mutation-rollback/SKILL.md +29 -0
  7. package/skills/hypothesis-generation/SKILL.md +26 -0
  8. package/skills/traceback-condensing/SKILL.md +26 -0
  9. package/src/castor_runner.js +469 -0
  10. package/src/config.js +1204 -0
  11. package/src/env.js +10 -0
  12. package/src/evo_engine.js +214 -0
  13. package/src/harness/core/events.js +75 -0
  14. package/src/harness/core/kernel.js +209 -0
  15. package/src/harness/evo/evaluator.js +156 -0
  16. package/src/harness/evo/evo_operator.js +550 -0
  17. package/src/harness/evo/lineage_dag.js +383 -0
  18. package/src/harness/evo/trace_repair.js +173 -0
  19. package/src/harness/evo/watchdog.js +72 -0
  20. package/src/harness/loop_detector.js +135 -0
  21. package/src/harness/runner.js +1216 -0
  22. package/src/harness/services/ast_service.js +1813 -0
  23. package/src/harness/services/event_logger.js +275 -0
  24. package/src/harness/services/mcp_bridge.js +408 -0
  25. package/src/harness/services/provider_vllm.js +728 -0
  26. package/src/harness/services/sandbox_fs.js +1238 -0
  27. package/src/harness/services/searxng_lifecycle.js +254 -0
  28. package/src/harness/services/shell_executor.js +264 -0
  29. package/src/harness/services/shell_validator.js +506 -0
  30. package/src/harness/services/web_service.js +828 -0
  31. package/src/platform.js +344 -0
  32. package/src/repetition_detector.js +139 -0
  33. package/src/semaphore.js +373 -0
  34. package/src/server_lifecycle.js +781 -0
  35. package/src/skills.js +400 -0
  36. package/src/state_pruner.js +392 -0
  37. package/src/task_registry.js +1357 -0
  38. package/src/telemetry.js +638 -0
  39. package/src/tools.js +997 -0
  40. package/src/wsl_bridge.js +629 -0
  41. package/src/wsl_env.js +171 -0
  42. 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
+ }