toolnexus 0.18.1 → 0.20.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/src/builtin.ts CHANGED
@@ -7,18 +7,196 @@
7
7
  * ToolResult{isError:true}, never a thrown exception across the boundary. Paths
8
8
  * resolve relative to the process working directory unless absolute.
9
9
  */
10
- import { spawn } from "node:child_process"
10
+ import { spawn, spawnSync } from "node:child_process"
11
11
  import fs from "node:fs/promises"
12
- import { existsSync, readdirSync } from "node:fs"
12
+ import { existsSync, readdirSync, realpathSync } from "node:fs"
13
+ import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js"
13
14
  import path from "node:path"
14
15
  import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
15
16
  import { pending } from "./types.js"
16
17
  import { mediaTypeFor } from "./content.js"
17
18
 
18
- /** Config for the single global builtin toggle (mirrors MCP isEnabled precedence). */
19
+ /**
20
+ * Config for the single global builtin toggle (mirrors MCP isEnabled precedence),
21
+ * plus the host boundary: which interpreter `bash` runs, what a relative path
22
+ * resolves against, and whether paths leaving it are refused (SPEC §4A, ADR 0034).
23
+ */
19
24
  export type BuiltinsConfig =
20
25
  | boolean
21
- | { enabled?: boolean; disabled?: boolean; tools?: Record<string, boolean> }
26
+ | {
27
+ enabled?: boolean
28
+ disabled?: boolean
29
+ tools?: Record<string, boolean>
30
+ /**
31
+ * The argv prefix `bash` runs a command with — `["sh","-c"]`,
32
+ * `["cmd","/d","/s","/c"]`, `["powershell","-NoProfile","-Command"]`. Set,
33
+ * it is used verbatim. Absent, an interpreter is DETECTED at toolkit
34
+ * construction (POSIX `sh -c`; Windows %COMSPEC% → pwsh → powershell → bash).
35
+ */
36
+ shell?: string[]
37
+ /**
38
+ * What a RELATIVE path means, for every builtin that touches the
39
+ * filesystem — including the paths inside apply_patch's patch text, and as
40
+ * bash's default workdir. Empty ⇒ the process cwd, i.e. today's behaviour.
41
+ */
42
+ baseDir?: string
43
+ /**
44
+ * Refuse any path whose CANONICAL form leaves baseDir (symlinks, Windows
45
+ * junctions and short names resolved on both sides), and Windows reserved
46
+ * device names. Default off. A guarantee about path resolution in the file
47
+ * builtins, NOT a sandbox — `bash` still reaches the whole filesystem.
48
+ */
49
+ confineToBaseDir?: boolean
50
+ }
51
+
52
+ /**
53
+ * How long a job gets between "please stop" and "stop". Fixed, and identical in
54
+ * every port, so a timeout means the same thing everywhere.
55
+ */
56
+ const KILL_GRACE_MS = 2000
57
+
58
+ const WIN_RESERVED = new Set([
59
+ "CON", "PRN", "AUX", "NUL",
60
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
61
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
62
+ ])
63
+
64
+ /** Does `name` resolve as an executable on PATH? (PATHEXT-aware on Windows.) */
65
+ function resolvesOnPath(name: string): boolean {
66
+ if (name.includes(path.sep) || name.includes("/")) return existsSync(name)
67
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)
68
+ const exts =
69
+ process.platform === "win32"
70
+ ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
71
+ : [""]
72
+ for (const dir of dirs) {
73
+ for (const ext of exts) {
74
+ if (existsSync(path.join(dir, name + ext))) return true
75
+ }
76
+ }
77
+ return false
78
+ }
79
+
80
+ /**
81
+ * The interpreters tried when the host names none. %COMSPEC% leads on Windows
82
+ * because PowerShell is routinely blocked by execution or application-control
83
+ * policy, while %COMSPEC% is always present.
84
+ */
85
+ function shellCandidates(): string[][] {
86
+ // `/bin/sh` first, then a PATH lookup: `shell: true` means literally
87
+ // `/bin/sh`, while spawning the bare name `sh` is a PATH lookup — two
88
+ // different binaries on a machine that has both. Naming the absolute path
89
+ // first is what makes dropping `shell: true` byte-identical here.
90
+ if (process.platform !== "win32") return [["/bin/sh", "-c"], ["sh", "-c"]]
91
+ const out: string[][] = []
92
+ if (process.env.COMSPEC) out.push([process.env.COMSPEC, "/d", "/s", "/c"])
93
+ out.push(
94
+ ["cmd.exe", "/d", "/s", "/c"],
95
+ ["pwsh", "-NoProfile", "-Command"],
96
+ ["powershell", "-NoProfile", "-Command"],
97
+ ["bash", "-lc"],
98
+ )
99
+ return out
100
+ }
101
+
102
+ /**
103
+ * The host boundary the builtins are allowed to know about. Constructed once,
104
+ * at toolkit construction: a missing interpreter is a configuration fact, and
105
+ * turn fourteen of a paid run is the expensive place to learn it.
106
+ */
107
+ class BuiltinEnv {
108
+ readonly shell: string[]
109
+ readonly shellError?: Error
110
+ readonly baseDir: string
111
+ readonly confine: boolean
112
+
113
+ constructor(cfg?: BuiltinsConfig) {
114
+ const obj = typeof cfg === "object" && cfg !== null ? cfg : {}
115
+ this.baseDir = obj.baseDir ?? ""
116
+ this.confine = obj.confineToBaseDir === true
117
+ if (obj.shell && obj.shell.length > 0) {
118
+ this.shell = obj.shell
119
+ return
120
+ }
121
+ const tried: string[] = []
122
+ for (const argv of shellCandidates()) {
123
+ tried.push(argv[0])
124
+ if (resolvesOnPath(argv[0])) {
125
+ this.shell = argv
126
+ return
127
+ }
128
+ }
129
+ this.shell = []
130
+ this.shellError = new Error(
131
+ `no shell interpreter found (tried: ${tried.join(", ")}); set builtins.shell, or disable the bash builtin with builtins.tools.bash = false`,
132
+ )
133
+ }
134
+
135
+ /** The interpreter argv, or the detection error when there is none. */
136
+ shellArgv(): string[] {
137
+ if (this.shellError) throw this.shellError
138
+ return this.shell
139
+ }
140
+
141
+ get shellLabel(): string {
142
+ return this.shell.join(" ")
143
+ }
144
+
145
+ /** The directory a command or a walk starts from. */
146
+ dir(): string {
147
+ return this.baseDir || process.cwd()
148
+ }
149
+
150
+ /**
151
+ * Map a tool-supplied path onto the filesystem: relative to baseDir (or, with
152
+ * none, exactly as before), and refused when confinement is on and the
153
+ * canonical target is outside the base.
154
+ */
155
+ resolvePath(p: string): string {
156
+ if (this.confine && !this.baseDir) {
157
+ throw new Error("confineToBaseDir is set but baseDir is empty")
158
+ }
159
+ const full = this.baseDir && !path.isAbsolute(p) ? path.join(this.baseDir, p) : p
160
+ if (!this.confine) return full
161
+ if (process.platform === "win32") {
162
+ const base = path.basename(full).split(".")[0].trim().toUpperCase()
163
+ if (WIN_RESERVED.has(base)) {
164
+ throw new Error(`${p} names a reserved device, which is not a file inside ${this.baseDir}`)
165
+ }
166
+ }
167
+ const canonBase = canonicalPath(this.baseDir)
168
+ const canonTarget = canonicalPath(full)
169
+ const rel = path.relative(canonBase, canonTarget)
170
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
171
+ throw new Error(`${p} resolves outside baseDir ${this.baseDir}`)
172
+ }
173
+ return full
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Resolve a path for comparison. Symlinks — and, on Windows, directory
179
+ * junctions and 8.3 short names — are resolved on the DEEPEST EXISTING
180
+ * ancestor and the remaining segments re-attached, because a file `write` is
181
+ * about to create has no real path, and a check that only works on existing
182
+ * files is not a check for `write`.
183
+ */
184
+ function canonicalPath(p: string): string {
185
+ const abs = path.resolve(p)
186
+ let cur = abs
187
+ let tail = ""
188
+ for (;;) {
189
+ try {
190
+ const resolved = realpathSync.native(cur)
191
+ return tail ? path.join(resolved, tail) : resolved
192
+ } catch {
193
+ const parent = path.dirname(cur)
194
+ if (parent === cur) return abs
195
+ tail = tail ? path.join(path.basename(cur), tail) : path.basename(cur)
196
+ cur = parent
197
+ }
198
+ }
199
+ }
22
200
 
23
201
  /**
24
202
  * Whether the builtin source is on. Default ON. Same precedence as MCP:
@@ -41,9 +219,13 @@ export function builtinsEnabled(cfg: BuiltinsConfig | undefined): boolean {
41
219
  export function selectBuiltins(cfg: BuiltinsConfig | undefined): Tool[] {
42
220
  if (!builtinsEnabled(cfg)) return []
43
221
  const map = typeof cfg === "object" ? cfg.tools : undefined
44
- const all = createBuiltinTools()
45
- if (!map) return all
46
- return all.filter((t) => map[t.name] !== false)
222
+ const all = createBuiltinTools(cfg)
223
+ const selected = map ? all.filter((t) => map[t.name] !== false) : all
224
+ // A missing interpreter is a construction-time failure, and only when `bash`
225
+ // survived the toggles: a host that disabled it should run fine on a box with
226
+ // no shell at all (ADR 0034 D1).
227
+ if (selected.some((t) => t.name === "bash")) builtinShell(cfg)
228
+ return selected
47
229
  }
48
230
 
49
231
  const err = (output: string, metadata?: Record<string, unknown>): ToolResult => ({ output, isError: true, metadata })
@@ -115,7 +297,9 @@ function walkFiles(root: string): string[] {
115
297
  const dir = stack.pop()!
116
298
  let entries
117
299
  try {
118
- entries = readdirSync(dir, { withFileTypes: true })
300
+ // EXPLICIT (A24). `glob` breaks at its cap, so an unsorted read changes WHICH files the
301
+ // model is shown, not merely their order — the same content bug as <skill_files> had.
302
+ entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }))
119
303
  } catch {
120
304
  continue
121
305
  }
@@ -136,7 +320,7 @@ function walkFiles(root: string): string[] {
136
320
  // individual tools
137
321
  // ---------------------------------------------------------------------------
138
322
 
139
- function bashTool(): Tool {
323
+ function bashTool(env: BuiltinEnv): Tool {
140
324
  return builtin(
141
325
  "bash",
142
326
  "Run a shell command and return its combined stdout+stderr. Non-zero exit is an error.",
@@ -151,38 +335,134 @@ function bashTool(): Tool {
151
335
  required: ["command"],
152
336
  additionalProperties: false,
153
337
  },
154
- (args) =>
338
+ (args, ctx) =>
155
339
  new Promise<ToolResult>((resolve) => {
156
340
  const command = String(args.command ?? "")
157
341
  if (!command) return resolve(err("bash: command is required"))
158
- const workdir = args.workdir ? String(args.workdir) : process.cwd()
342
+ let argv: string[]
343
+ let workdir: string
344
+ try {
345
+ argv = env.shellArgv()
346
+ workdir = args.workdir ? env.resolvePath(String(args.workdir)) : env.dir()
347
+ } catch (e) {
348
+ return resolve(err(`bash: ${(e as Error).message}`))
349
+ }
159
350
  const timeout = typeof args.timeout === "number" ? args.timeout : 60_000
160
- const child = spawn(command, { shell: true, cwd: workdir })
351
+ const meta: Record<string, unknown> = { shell: env.shellLabel }
352
+
353
+ // NOT `shell: true`: on Windows that silently means cmd.exe while every
354
+ // other port fails loudly, and it hides WHICH interpreter ran. `detached`
355
+ // puts the child in its own process group so a kill reaches the command
356
+ // AND everything it started — without it the kill reaches the
357
+ // interpreter only and the real work runs on, reparented (ADR 0034 D4).
358
+ const child = spawn(argv[0], [...argv.slice(1), command], {
359
+ cwd: workdir,
360
+ detached: process.platform !== "win32",
361
+ })
161
362
  let out = ""
162
- let timedOut = false
163
- const timer = setTimeout(() => {
164
- timedOut = true
165
- child.kill("SIGKILL")
166
- }, timeout)
363
+ let stopped: "" | "timeout" | "cancelled" = ""
364
+ let killedTree = false
365
+ let settled = false
366
+
367
+ // Captured once, at spawn: `detached` makes the child a process-group
368
+ // LEADER, so its pid is the pgid. Reading it later from `child` would
369
+ // risk signalling by a pid the runtime has already let go of.
370
+ const pgid = child.pid
371
+
372
+ const killJob = (graceful: boolean): boolean => {
373
+ // Nothing may be signalled once this call has settled: a stale timer
374
+ // firing after the fact would aim at a pid the OS may have reissued,
375
+ // and a NEGATED pid is a whole process group.
376
+ if (settled) return false
377
+ if (typeof pgid !== "number" || !Number.isInteger(pgid) || pgid <= 1) return false
378
+ try {
379
+ if (process.platform === "win32") {
380
+ // Windows has NO graceful termination for a console process, and
381
+ // asking anyway is worse than not asking: measured on a native
382
+ // Windows box, `taskkill /T` without `/F` refuses every console
383
+ // process in the tree ("can only be terminated forcefully") while
384
+ // still being able to take the parent down — which reparents the
385
+ // grandchild and leaves it running. So the job is terminated at
386
+ // once there, and the grace window is a POSIX effect (ADR 0034 D4).
387
+ const r = spawnSync("taskkill", ["/T", "/F", "/PID", String(pgid)])
388
+ return r.status === 0
389
+ }
390
+ // Never signal a REAPED child by negated pid: that pid may belong to
391
+ // something else by now, and the blast radius is a whole process
392
+ // GROUP. (On Windows above, taskkill targets one pid and a dead pid
393
+ // is merely an error, so the same guard is not needed there — and
394
+ // must not be applied, because the tree can outlive the child.)
395
+ if (child.exitCode !== null || child.signalCode !== null) return false
396
+ process.kill(-pgid, graceful ? "SIGTERM" : "SIGKILL")
397
+ return true
398
+ } catch {
399
+ return false
400
+ }
401
+ }
402
+
403
+ // Ask, wait out the grace window, then insist. A runner that gets
404
+ // SIGTERM removes its temp directories; one that gets SIGKILL does not.
405
+ const stop = (why: "timeout" | "cancelled") => {
406
+ if (stopped) return
407
+ stopped = why
408
+ killedTree = killJob(true)
409
+ graceTimer = setTimeout(() => {
410
+ if (killJob(false)) killedTree = true
411
+ }, KILL_GRACE_MS)
412
+ }
413
+
414
+ let graceTimer: NodeJS.Timeout | undefined
415
+ const timer = setTimeout(() => stop("timeout"), timeout)
416
+ const signal = ctx?.signal
417
+ const onAbort = () => stop("cancelled")
418
+ signal?.addEventListener?.("abort", onAbort, { once: true })
419
+
420
+ const finish = (result: ToolResult) => {
421
+ if (settled) return
422
+ settled = true
423
+ clearTimeout(timer)
424
+ if (graceTimer) clearTimeout(graceTimer)
425
+ signal?.removeEventListener?.("abort", onAbort)
426
+ resolve(result)
427
+ }
428
+
167
429
  child.stdout?.on("data", (d) => (out += d.toString()))
168
430
  child.stderr?.on("data", (d) => (out += d.toString()))
169
- child.on("error", (e) => {
170
- clearTimeout(timer)
171
- resolve(err(`bash: ${e.message}`))
172
- })
431
+ child.on("error", (e) => finish(err(`bash: ${e.message}`, meta)))
173
432
  child.on("close", (code) => {
174
- clearTimeout(timer)
175
- if (timedOut) return resolve(err(`bash: command timed out after ${timeout}ms\n${out}`))
176
- if (code !== 0) {
177
- return resolve(err(`${out}\nbash: command exited with code ${code}`, { exitCode: code }))
433
+ if (stopped) {
434
+ // WINDOWS ONLY, and the platform guard is the point. There, the
435
+ // direct child closing does not mean the job is gone — a detached
436
+ // grandchild outlives it — so the tree is terminated here by pid.
437
+ //
438
+ // On POSIX this must NOT run. `close` only fires once the stdio the
439
+ // grandchild inherited has closed, so the job is already gone; and
440
+ // signalling `-pid` after the child has been reaped aims at whatever
441
+ // process now owns that pid. PIDs recycle fast on a busy CI box, and
442
+ // the blast radius of a negated pid is a whole process GROUP — which
443
+ // is how this killed sibling test workers on the GitHub runner while
444
+ // passing on macOS, in a container, and on its own.
445
+ if (!killedTree && process.platform === "win32") killedTree = killJob(false)
446
+ meta.timedOut = stopped === "timeout"
447
+ meta.killedTree = killedTree
448
+ return finish(
449
+ err(
450
+ stopped === "timeout"
451
+ ? `bash: command timed out after ${timeout}ms\n${out}`
452
+ : `bash: command cancelled\n${out}`,
453
+ meta,
454
+ ),
455
+ )
178
456
  }
179
- resolve(ok(out, { exitCode: code }))
457
+ meta.exitCode = code
458
+ if (code !== 0) return finish(err(`${out}\nbash: command exited with code ${code}`, meta))
459
+ finish(ok(out, meta))
180
460
  })
181
461
  }),
182
462
  )
183
463
  }
184
464
 
185
- function readTool(): Tool {
465
+ function readTool(env: BuiltinEnv): Tool {
186
466
  return builtin(
187
467
  "read",
188
468
  "Read a file. Recognised media (png/jpg/jpeg/gif/webp/pdf/mp3/wav) comes back as a content part; anything else is read as UTF-8 text, with offset/limit returning that line window.",
@@ -200,14 +480,16 @@ function readTool(): Tool {
200
480
  const p = String(args.path ?? "")
201
481
  if (!p) return err("read: path is required")
202
482
  let bytes: Buffer
483
+ let full: string
203
484
  try {
204
- bytes = await fs.readFile(p)
485
+ full = env.resolvePath(p)
486
+ bytes = await fs.readFile(full)
205
487
  } catch (e) {
206
488
  return err(`read: ${e instanceof Error ? e.message : String(e)}`)
207
489
  }
208
490
  // §6: a recognised media extension comes back as a part. The table is fixed — no magic-byte
209
491
  // sniffing and no platform mime database, whose contents vary per machine.
210
- const media = mediaTypeFor(p)
492
+ const media = mediaTypeFor(full)
211
493
  if (media) {
212
494
  return {
213
495
  output: `${p} (${media.mimeType}, ${bytes.length} bytes)`,
@@ -234,7 +516,7 @@ function readTool(): Tool {
234
516
  )
235
517
  }
236
518
 
237
- function writeTool(): Tool {
519
+ function writeTool(env: BuiltinEnv): Tool {
238
520
  return builtin(
239
521
  "write",
240
522
  "Write content to a file (create/overwrite), creating parent directories.",
@@ -251,15 +533,21 @@ function writeTool(): Tool {
251
533
  const p = String(args.path ?? "")
252
534
  if (!p) return err("write: path is required")
253
535
  const content = typeof args.content === "string" ? args.content : String(args.content ?? "")
254
- await fs.mkdir(path.dirname(path.resolve(p)), { recursive: true })
255
- await fs.writeFile(p, content, "utf8")
536
+ let full: string
537
+ try {
538
+ full = env.resolvePath(p)
539
+ } catch (e) {
540
+ return err(`write: ${e instanceof Error ? e.message : String(e)}`)
541
+ }
542
+ await fs.mkdir(path.dirname(path.resolve(full)), { recursive: true })
543
+ await fs.writeFile(full, content, "utf8")
256
544
  const bytes = Buffer.byteLength(content, "utf8")
257
545
  return ok(`Wrote ${bytes} bytes to ${p}`, { bytes })
258
546
  },
259
547
  )
260
548
  }
261
549
 
262
- function editTool(): Tool {
550
+ function editTool(env: BuiltinEnv): Tool {
263
551
  return builtin(
264
552
  "edit",
265
553
  "Exact-string replace in a file. Default replaces a single unique occurrence; replaceAll replaces all.",
@@ -283,8 +571,10 @@ function editTool(): Tool {
283
571
  const oldString = args.oldString
284
572
  const newString = typeof args.newString === "string" ? args.newString : String(args.newString ?? "")
285
573
  let content: string
574
+ let full: string
286
575
  try {
287
- content = await fs.readFile(p, "utf8")
576
+ full = env.resolvePath(p)
577
+ content = await fs.readFile(full, "utf8")
288
578
  } catch (e) {
289
579
  return err(`edit: ${e instanceof Error ? e.message : String(e)}`)
290
580
  }
@@ -305,7 +595,7 @@ function editTool(): Tool {
305
595
  )
306
596
  }
307
597
 
308
- function grepTool(): Tool {
598
+ function grepTool(env: BuiltinEnv): Tool {
309
599
  return builtin(
310
600
  "grep",
311
601
  "Search file contents by regex under a directory. Output is file:line:text matches.",
@@ -329,13 +619,21 @@ function grepTool(): Tool {
329
619
  } catch (e) {
330
620
  return err(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`)
331
621
  }
332
- const root = args.path ? String(args.path) : process.cwd()
622
+ let root: string
623
+ try {
624
+ root = args.path ? env.resolvePath(String(args.path)) : env.dir()
625
+ } catch (e) {
626
+ return err(`grep: ${e instanceof Error ? e.message : String(e)}`)
627
+ }
333
628
  const include = args.include ? String(args.include) : undefined
334
629
  const limit = typeof args.limit === "number" ? args.limit : 100
335
- const matches: string[] = []
630
+ // Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
631
+ // CONTENT and the order of the results were whatever the walk happened to reach first.
632
+ // Now: collect every match, order by relative path (code point) then line number ascending,
633
+ // and truncate last. The emitted path and the sort key are the same string (A28).
634
+ const hits: Array<{ rel: string; line: number; text: string }> = []
336
635
  for (const file of walkFiles(root)) {
337
- if (matches.length >= limit) break
338
- const rel = path.relative(root, file)
636
+ const rel = toPosixPath(path.relative(root, file), path.sep)
339
637
  if (include && !matchGlob(rel, include)) continue
340
638
  let text: string
341
639
  try {
@@ -345,16 +643,17 @@ function grepTool(): Tool {
345
643
  }
346
644
  const lines = text.split("\n")
347
645
  for (let i = 0; i < lines.length; i++) {
348
- if (matches.length >= limit) break
349
- if (re.test(lines[i])) matches.push(`${file}:${i + 1}:${lines[i]}`)
646
+ if (re.test(lines[i])) hits.push({ rel, line: i + 1, text: lines[i] })
350
647
  }
351
648
  }
649
+ hits.sort((a, b) => compareCodePoints(a.rel, b.rel) || a.line - b.line)
650
+ const matches = hits.slice(0, limit).map((h) => `${h.rel}:${h.line}:${h.text}`)
352
651
  return ok(matches.join("\n"), { count: matches.length })
353
652
  },
354
653
  )
355
654
  }
356
655
 
357
- function globTool(): Tool {
656
+ function globTool(env: BuiltinEnv): Tool {
358
657
  return builtin(
359
658
  "glob",
360
659
  "List files matching a glob under a directory. Output is newline-joined relative paths.",
@@ -371,15 +670,22 @@ function globTool(): Tool {
371
670
  async (args) => {
372
671
  const pattern = String(args.pattern ?? "")
373
672
  if (!pattern) return err("glob: pattern is required")
374
- const root = args.path ? String(args.path) : process.cwd()
673
+ let root: string
674
+ try {
675
+ root = args.path ? env.resolvePath(String(args.path)) : env.dir()
676
+ } catch (e) {
677
+ return err(`glob: ${e instanceof Error ? e.message : String(e)}`)
678
+ }
375
679
  const limit = typeof args.limit === "number" ? args.limit : 100
680
+ // Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
681
+ // the filesystem decide WHICH files the model sees, not merely their order. The emitted
682
+ // string and the sort key are the SAME `/`-separated relative path (A28).
376
683
  const found: string[] = []
377
684
  for (const file of walkFiles(root)) {
378
- if (found.length >= limit) break
379
- const rel = path.relative(root, file)
685
+ const rel = toPosixPath(path.relative(root, file), path.sep)
380
686
  if (matchGlob(rel, pattern)) found.push(rel)
381
687
  }
382
- found.sort()
688
+ found.sort(compareCodePoints)
383
689
  return ok(found.slice(0, limit).join("\n"), { count: Math.min(found.length, limit) })
384
690
  },
385
691
  )
@@ -608,7 +914,7 @@ function applyUpdate(content: string, body: string[]): string {
608
914
  return result
609
915
  }
610
916
 
611
- function applyPatchTool(): Tool {
917
+ function applyPatchTool(env: BuiltinEnv): Tool {
612
918
  return builtin(
613
919
  "apply_patch",
614
920
  "Apply a patch (Begin/End Patch grammar: Add/Update/Delete File). Atomic — a non-matching hunk aborts with no writes.",
@@ -629,6 +935,14 @@ function applyPatchTool(): Tool {
629
935
  } catch (e) {
630
936
  return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`)
631
937
  }
938
+ // The paths live INSIDE the patch text, not in the arguments, so a host
939
+ // cannot rewrite them from a hook — this is the one case that genuinely
940
+ // needs the base directory to be library-side (ADR 0034 D2).
941
+ try {
942
+ for (const op of ops) op.path = env.resolvePath(op.path)
943
+ } catch (e) {
944
+ return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`)
945
+ }
632
946
  // Stage every write/delete first; only touch the filesystem once all hunks apply.
633
947
  const writes: Array<{ path: string; content: string }> = []
634
948
  const deletes: string[] = []
@@ -669,17 +983,27 @@ function applyPatchTool(): Tool {
669
983
  * for parity: bash, read, write, edit, grep, glob, webfetch, question,
670
984
  * apply_patch, todowrite.
671
985
  */
672
- export function createBuiltinTools(): Tool[] {
986
+ export function createBuiltinTools(cfg?: BuiltinsConfig): Tool[] {
987
+ const env = new BuiltinEnv(cfg)
673
988
  return [
674
- bashTool(),
675
- readTool(),
676
- writeTool(),
677
- editTool(),
678
- grepTool(),
679
- globTool(),
989
+ bashTool(env),
990
+ readTool(env),
991
+ writeTool(env),
992
+ editTool(env),
993
+ grepTool(env),
994
+ globTool(env),
680
995
  webfetchTool(),
681
996
  questionTool(),
682
- applyPatchTool(),
997
+ applyPatchTool(env),
683
998
  todowriteTool(),
684
999
  ]
685
1000
  }
1001
+
1002
+ /**
1003
+ * The interpreter the builtins resolved for `bash` — what a host prints, and
1004
+ * what `metadata.shell` carries on every bash result. Throws the same error
1005
+ * `selectBuiltins` fails construction with when nothing resolves.
1006
+ */
1007
+ export function builtinShell(cfg?: BuiltinsConfig): string[] {
1008
+ return new BuiltinEnv(cfg).shellArgv()
1009
+ }