@hydraharness/harness-tool-fs 0.1.1-rc.6

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/lib/index.js ADDED
@@ -0,0 +1,1240 @@
1
+ import z from "@hydraharness/schemastery";
2
+ import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "@hydraharness/harness-tools";
3
+ import { FsError } from "@hydraharness/harness-fs";
4
+ import { ESCALATION_TARGETS, approveEscalation, canonicalPath, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from "@hydraharness/harness-sandbox";
5
+ import { structuredPatch } from "diff";
6
+ import { basename, extname } from "node:path";
7
+ import { AttachmentError, AttachmentId } from "@hydraharness/harness-attachment";
8
+ //#region lib/types/read-render.js
9
+ /**
10
+ * Pure read presentation: turn provider-decoded text into a bounded, line-numbered window and
11
+ * model-facing envelope. Chunk scanning caps the current line, so even one newline-free giant
12
+ * line cannot grow memory without bound.
13
+ * @module @hydraharness/harness-tool-fs/read-render
14
+ */
15
+ /** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
16
+ const READ_MAX_LINE_LENGTH = 2e3;
17
+ /** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */
18
+ const READ_MAX_BYTES = 50 * 1024;
19
+ function newAccumulator() {
20
+ return {
21
+ lines: [],
22
+ totalLines: 0,
23
+ outputBytes: 0,
24
+ truncatedByBytes: false
25
+ };
26
+ }
27
+ function truncateLine(line, maxLineLength) {
28
+ return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line;
29
+ }
30
+ function lineByteSize(line, currentLineCount) {
31
+ return Buffer.byteLength(line, "utf8") + (currentLineCount > 0 ? 1 : 0);
32
+ }
33
+ function consumeLine(acc, rawLine, request) {
34
+ acc.totalLines += 1;
35
+ if (acc.truncatedByBytes || acc.totalLines < request.offset || acc.lines.length >= request.limit) return;
36
+ const text = truncateLine(rawLine, request.maxLineLength);
37
+ const bytes = lineByteSize(text, acc.lines.length);
38
+ if (acc.outputBytes + bytes > request.maxBytes) {
39
+ acc.truncatedByBytes = true;
40
+ return;
41
+ }
42
+ acc.outputBytes += bytes;
43
+ acc.lines.push({
44
+ number: acc.totalLines,
45
+ text
46
+ });
47
+ }
48
+ function stripCarriageReturn(line) {
49
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
50
+ }
51
+ function finish(acc, request, displayPath) {
52
+ if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, "FS_NOT_FOUND");
53
+ return {
54
+ lines: acc.lines,
55
+ totalLines: acc.totalLines,
56
+ truncatedByBytes: acc.truncatedByBytes
57
+ };
58
+ }
59
+ /**
60
+ * Build one window from streamed or whole-file chunks, enforcing line and byte caps while still
61
+ * scanning to an exact total line count, and throwing `FS_NOT_FOUND` when the requested offset is
62
+ * past EOF.
63
+ * @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
64
+ * @param request - the resolved window; the caller has already applied its defaults and caps.
65
+ * @param displayPath - the caller-facing path used in the offset-out-of-range error.
66
+ * @returns the numbered window lines, the total line count seen, and the byte-cap truncation flag.
67
+ */
68
+ async function buildWindow(chunks, request, displayPath) {
69
+ const acc = newAccumulator();
70
+ const lineBufferCap = request.maxLineLength + 1;
71
+ let lineBuffer = "";
72
+ function appendToLineBuffer(segment) {
73
+ if (lineBuffer.length >= lineBufferCap) return;
74
+ lineBuffer += segment;
75
+ if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap);
76
+ }
77
+ function flushLine() {
78
+ consumeLine(acc, stripCarriageReturn(lineBuffer), request);
79
+ lineBuffer = "";
80
+ }
81
+ for await (const chunk of chunks) {
82
+ let startPos = 0;
83
+ let newlinePos;
84
+ while ((newlinePos = chunk.indexOf("\n", startPos)) !== -1) {
85
+ appendToLineBuffer(chunk.slice(startPos, newlinePos));
86
+ flushLine();
87
+ startPos = newlinePos + 1;
88
+ }
89
+ appendToLineBuffer(chunk.slice(startPos));
90
+ }
91
+ if (lineBuffer.length > 0) flushLine();
92
+ return finish(acc, request, displayPath);
93
+ }
94
+ /**
95
+ * Format a read outcome as one OpenCode-style line-numbered text block body.
96
+ * @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
97
+ * @param outcome - the windowed read to render.
98
+ * @returns the model-facing envelope: numbered lines plus a continuation or end-of-file footer.
99
+ */
100
+ function formatReadOutput(displayPath, outcome) {
101
+ const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1);
102
+ let footer;
103
+ if (outcome.truncatedByBytes) footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`;
104
+ else if (endLine < outcome.totalLines) footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`;
105
+ else footer = `(End of file - total ${outcome.totalLines} lines)`;
106
+ return `<path>${displayPath}</path>
107
+ <type>file</type>
108
+ <content>
109
+ ${outcome.lines.length > 0 ? `${outcome.lines.map((line) => `${line.number}: ${line.text}`).join("\n")}\n\n${footer}` : footer}
110
+ </content>`;
111
+ }
112
+ /**
113
+ * Lowercased file-extension to syntax-highlighting language hint. Keys are the
114
+ * extension without its dot; a UI treats an absent key as plain text. The map is
115
+ * intentionally small — common source, config, and markup extensions a
116
+ * line-numbered code view benefits from highlighting — not an exhaustive registry.
117
+ */
118
+ const LANG_BY_EXTENSION = {
119
+ ts: "ts",
120
+ tsx: "tsx",
121
+ mts: "ts",
122
+ cts: "ts",
123
+ js: "js",
124
+ jsx: "jsx",
125
+ mjs: "js",
126
+ cjs: "js",
127
+ json: "json",
128
+ jsonc: "json",
129
+ py: "py",
130
+ rb: "rb",
131
+ go: "go",
132
+ rs: "rs",
133
+ java: "java",
134
+ c: "c",
135
+ h: "c",
136
+ cc: "cpp",
137
+ cpp: "cpp",
138
+ hpp: "cpp",
139
+ cxx: "cpp",
140
+ cs: "cs",
141
+ kt: "kotlin",
142
+ swift: "swift",
143
+ php: "php",
144
+ sh: "sh",
145
+ bash: "sh",
146
+ zsh: "sh",
147
+ yaml: "yaml",
148
+ yml: "yaml",
149
+ toml: "toml",
150
+ ini: "ini",
151
+ md: "md",
152
+ markdown: "md",
153
+ mdx: "mdx",
154
+ html: "html",
155
+ htm: "html",
156
+ css: "css",
157
+ scss: "scss",
158
+ less: "less",
159
+ sql: "sql",
160
+ xml: "xml",
161
+ lua: "lua"
162
+ };
163
+ /**
164
+ * Derive a syntax-highlighting language hint from a read path's file extension.
165
+ * Pure and case-insensitive on the extension; a dotfile with no extension
166
+ * (`.gitignore`) and an unknown extension both yield `undefined`.
167
+ * @param path - the model-facing path the read reported.
168
+ * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none.
169
+ */
170
+ function langFromPath(path) {
171
+ const base = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
172
+ const dot = base.lastIndexOf(".");
173
+ if (dot <= 0) return void 0;
174
+ const ext = base.slice(dot + 1).toLowerCase();
175
+ return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : void 0;
176
+ }
177
+ /**
178
+ * Whether `value` is a valid {@link FileTextLine} (defensive narrowing from
179
+ * opaque `meta`). `number` must be a 1-based integer line number, since a card
180
+ * rendered from a zero, fractional, or non-finite line number would violate the
181
+ * 1-based numbering contract the read window promises.
182
+ */
183
+ function isFileTextLine(value) {
184
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
185
+ const { number, text } = value;
186
+ return typeof number === "number" && Number.isInteger(number) && number >= 1 && typeof text === "string";
187
+ }
188
+ /**
189
+ * Narrow opaque live or replayed result metadata to a structured read window.
190
+ * Malformed metadata returns `undefined` so presentation can fall back to the
191
+ * generic text card instead of throwing during replay. Beyond shape, the
192
+ * semantic contract of a read window is enforced against replayed JSON that is
193
+ * well-typed but out of range: `offset` must be a 1-based integer, `totalLines`
194
+ * must be a non-negative integer, each line number must be a 1-based integer no
195
+ * less than `offset`, the line numbers must strictly increase, and no line number
196
+ * may exceed `totalLines`. Any violation declines to the generic fallback rather
197
+ * than emitting a card that misnumbers or overcounts.
198
+ * @param meta - result metadata.
199
+ * @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data.
200
+ */
201
+ function readMetaFromMeta(meta) {
202
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
203
+ const { path, offset, lines, totalLines, lang } = meta;
204
+ if (typeof path !== "string" || typeof totalLines !== "number" || typeof offset !== "number") return void 0;
205
+ if (!Number.isInteger(offset) || offset < 1) return void 0;
206
+ if (!Number.isInteger(totalLines) || totalLines < 0) return void 0;
207
+ if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return void 0;
208
+ if (lang !== void 0 && typeof lang !== "string") return void 0;
209
+ let previous = offset - 1;
210
+ for (const { number } of lines) {
211
+ if (number <= previous || number > totalLines) return void 0;
212
+ previous = number;
213
+ }
214
+ return {
215
+ path,
216
+ offset,
217
+ lines,
218
+ totalLines,
219
+ ...lang === void 0 ? {} : { lang }
220
+ };
221
+ }
222
+ //#endregion
223
+ //#region lib/types/session-cwd.js
224
+ /**
225
+ * Derive the working directory a filesystem tool resolves relative paths against: the calling
226
+ * agent's per-session workspace (`exec.agent.session.header.cwd`), so each session's
227
+ * `read`/`write`/`edit` act on ITS workspace, not the server's launch dir — mirroring how
228
+ * `@hydraharness/harness-tool-bash` defaults a bash `workdir` to the session cwd.
229
+ * Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading
230
+ * `process.cwd()` at the tool boundary.
231
+ * @module @hydraharness/harness-tool-fs/session-cwd
232
+ */
233
+ const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
234
+ /**
235
+ * The session workspace cwd for this call, or `undefined` when none applies.
236
+ * @param exec - the tool-execution context; only its optional `agent` is read.
237
+ * @param requestedPath - the path the provider will resolve; parent traversal
238
+ * makes a symlinked cwd's filesystem identity observable.
239
+ * @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
240
+ */
241
+ function sessionCwd(exec, requestedPath) {
242
+ const cwd = exec.agent?.session.header.cwd;
243
+ if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
244
+ return canonicalPath(cwd);
245
+ }
246
+ /**
247
+ * Resolution options shared by all model-facing filesystem tools.
248
+ * @param exec - the tool-execution context supplying session cwd and cancellation.
249
+ * @param requestedPath - the path the provider will resolve.
250
+ * @param policyWorkspaceRoot - resolved per-call root, when a mutation carries sandbox policy.
251
+ * @returns provider resolution options for the current tool call.
252
+ */
253
+ function sessionResolveOptions(exec, requestedPath, policyWorkspaceRoot) {
254
+ const cwd = policyWorkspaceRoot ?? sessionCwd(exec, requestedPath);
255
+ return {
256
+ ...cwd !== void 0 ? { cwd } : {},
257
+ signal: exec.signal
258
+ };
259
+ }
260
+ //#endregion
261
+ //#region lib/types/read-target.js
262
+ /**
263
+ * Shared path resolution and regular-file validation for model-facing read tools.
264
+ * @module @hydraharness/harness-tool-fs/src/read-target
265
+ */
266
+ /**
267
+ * Resolve a model-supplied path, observe absence, and require a regular file.
268
+ * @param ctx - the plugin context providing filesystem resolution and observation events.
269
+ * @param exec - the current tool execution, including session cwd and cancellation.
270
+ * @param requestedPath - the raw path supplied to the tool.
271
+ * @returns the resolved target and its single stat result.
272
+ */
273
+ async function resolveRegularReadTarget(ctx, exec, requestedPath) {
274
+ const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec, requestedPath));
275
+ const info = await ctx.fs.stat(target, exec.signal);
276
+ if (info === void 0) {
277
+ ctx.emit("fs/observed", target, { kind: "absent" }, exec);
278
+ throw new FsError(`cannot read "${target.displayPath}": not found`, "FS_NOT_FOUND");
279
+ }
280
+ if (info.type !== "file") throw new FsError(`cannot read "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
281
+ return {
282
+ target,
283
+ info
284
+ };
285
+ }
286
+ //#endregion
287
+ //#region lib/types/read.js
288
+ /**
289
+ * Model-facing UTF-8 read. It performs one provider stat for type, routing, and observed version,
290
+ * streams large or size-unknown files, renders a bounded window, then emits the observation.
291
+ * @module @hydraharness/harness-tool-fs/src/read
292
+ */
293
+ /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
294
+ const READ_LIMIT = 2e3;
295
+ /**
296
+ * Default streaming threshold (the `readStreamMinSize` config): files at or
297
+ * above this size stream; smaller files read whole into memory.
298
+ */
299
+ const STREAM_MIN_SIZE = 10 * 1024 * 1024;
300
+ function parsePositiveInteger(value, name) {
301
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
302
+ return value;
303
+ }
304
+ /**
305
+ * Keep a defense-in-depth check after schema validation. `maxLimit` is the deployment's line cap.
306
+ * @param args - the schema-validated raw tool arguments; `offset`/`limit` must be positive integers when given.
307
+ * @param maxLimit - the configured line cap: both the default `limit` and the largest one accepted.
308
+ * @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`.
309
+ */
310
+ function parseReadArgs(args, maxLimit) {
311
+ if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
312
+ const offset = args.offset === void 0 ? 1 : parsePositiveInteger(args.offset, "offset");
313
+ const limit = args.limit === void 0 ? maxLimit : parsePositiveInteger(args.limit, "limit");
314
+ if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`);
315
+ return {
316
+ filePath: args.file_path,
317
+ offset,
318
+ limit
319
+ };
320
+ }
321
+ /**
322
+ * Register the `read` tool and its system-prompt guidance.
323
+ * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
324
+ * @param caps - the deployment's resolved read caps (plugin config after defaulting).
325
+ */
326
+ function applyReadTool(ctx, caps) {
327
+ ctx.systemPrompt.section({
328
+ name: "tool:read",
329
+ order: 100,
330
+ text: "Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. For exact line counts, use the returned total. For exact occurrence counts, enumerate every occurrence in complete returned evidence; never infer a count from a partial or skimmed read. For ordered evidence such as logs, determine first or last from the smallest or largest sequence or position across all relevant events; do not skip interaction tools."
331
+ });
332
+ ctx.tools.register(defineTool({
333
+ name: "read",
334
+ description: "Read a UTF-8 text file and return line-numbered content.",
335
+ parameters: {
336
+ file_path: {
337
+ type: "string",
338
+ required: true,
339
+ description: "Path to read, resolved by the filesystem backend."
340
+ },
341
+ offset: {
342
+ type: "integer",
343
+ minimum: 1,
344
+ description: "1-based first line to return. Defaults to 1."
345
+ },
346
+ limit: {
347
+ type: "integer",
348
+ minimum: 1,
349
+ maximum: caps.limit,
350
+ description: `Maximum number of lines to return. Defaults to ${caps.limit}; values above ${caps.limit} are rejected.`
351
+ }
352
+ },
353
+ output: {
354
+ schema: {
355
+ type: "object",
356
+ additionalProperties: false,
357
+ properties: {
358
+ path: {
359
+ type: "string",
360
+ required: true
361
+ },
362
+ offset: {
363
+ type: "integer",
364
+ required: true
365
+ },
366
+ lines: {
367
+ type: "array",
368
+ required: true,
369
+ items: {
370
+ type: "object",
371
+ additionalProperties: false,
372
+ properties: {
373
+ number: {
374
+ type: "integer",
375
+ required: true
376
+ },
377
+ text: {
378
+ type: "string",
379
+ required: true
380
+ }
381
+ }
382
+ }
383
+ },
384
+ totalLines: {
385
+ type: "integer",
386
+ required: true
387
+ }
388
+ }
389
+ },
390
+ render: (args, value) => {
391
+ const input = parseReadArgs(args, caps.limit);
392
+ const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1);
393
+ const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines;
394
+ return [{
395
+ type: "text",
396
+ text: formatReadOutput(value.path, {
397
+ offset: value.offset,
398
+ lines: value.lines,
399
+ totalLines: value.totalLines,
400
+ ...truncatedByBytes ? { truncatedByBytes: true } : {}
401
+ })
402
+ }];
403
+ },
404
+ presentationMeta: (_args, value) => {
405
+ const lang = langFromPath(value.path);
406
+ return {
407
+ path: value.path,
408
+ offset: value.offset,
409
+ lines: value.lines.map(({ number, text }) => ({
410
+ number,
411
+ text
412
+ })),
413
+ totalLines: value.totalLines,
414
+ ...lang === void 0 ? {} : { lang }
415
+ };
416
+ }
417
+ },
418
+ isConcurrencySafe: () => true,
419
+ async execute(args, exec) {
420
+ const input = parseReadArgs(args, caps.limit);
421
+ const { target, info } = await resolveRegularReadTarget(ctx, exec, input.filePath);
422
+ const window = await buildWindow(info.size === void 0 || info.size >= caps.streamMinSize ? await ctx.fs.streamText(target, exec.signal) : [await ctx.fs.readText(target, exec.signal)], {
423
+ offset: input.offset,
424
+ limit: input.limit,
425
+ maxLineLength: caps.maxLineLength,
426
+ maxBytes: caps.maxBytes
427
+ }, target.displayPath);
428
+ const outcome = {
429
+ path: target.displayPath,
430
+ offset: input.offset,
431
+ lines: window.lines,
432
+ totalLines: window.totalLines
433
+ };
434
+ ctx.emit("fs/observed", target, {
435
+ kind: "present",
436
+ version: info.version
437
+ }, exec);
438
+ return outcome;
439
+ },
440
+ presentResult(_args, result) {
441
+ if (result.isError) return void 0;
442
+ const meta = readMetaFromMeta(result.meta);
443
+ if (meta === void 0) return void 0;
444
+ const only = result.content.length === 1 ? result.content[0] : void 0;
445
+ const text = only?.type === "text" ? only.text : void 0;
446
+ if (text === void 0) return void 0;
447
+ const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1];
448
+ if (body === void 0) return void 0;
449
+ return {
450
+ card: "read",
451
+ path: meta.path,
452
+ offset: meta.offset,
453
+ lines: meta.lines,
454
+ totalLines: meta.totalLines,
455
+ ...meta.lang === void 0 ? {} : { lang: meta.lang },
456
+ content: [{
457
+ type: "text",
458
+ text: body
459
+ }]
460
+ };
461
+ },
462
+ presentCall(args) {
463
+ const { offset, limit } = args;
464
+ const window = limit !== void 0 && limit > 0 ? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` : offset !== void 0 ? ` (from line ${offset})` : "";
465
+ return {
466
+ card: "generic",
467
+ title: `Read ${args.file_path}${window}`,
468
+ kind: "read",
469
+ locations: [{
470
+ path: args.file_path,
471
+ line: offset ?? 1
472
+ }]
473
+ };
474
+ }
475
+ }));
476
+ }
477
+ /**
478
+ * Compute one {@link FileDiff} per hunk between `before` and `after`, each carrying the
479
+ * applied change plus {@link DIFF_CONTEXT} context lines. Pure insertions use `oldText: null`,
480
+ * patch-only no-newline markers are omitted, and scattered replacements remain separate hunks.
481
+ *
482
+ * @param path - the path stamped on every produced diff (the model-facing `file_path`; the
483
+ * bridge relativizes it).
484
+ * @param before - the file text before the change (the backend's LF-normalized diff basis).
485
+ * @param after - the file text after the change, on the same basis.
486
+ * @returns one diff per applied hunk, in file order; empty when the texts are identical.
487
+ */
488
+ function computeHunkDiffs(path, before, after) {
489
+ const patch = structuredPatch("", "", before, after, void 0, void 0, { context: 3 });
490
+ const diffs = [];
491
+ for (const hunk of patch.hunks) {
492
+ const oldLines = [];
493
+ const newLines = [];
494
+ for (const line of hunk.lines) {
495
+ if (line.startsWith("\\")) continue;
496
+ const text = line.slice(1);
497
+ if (line.startsWith("-")) oldLines.push(text);
498
+ else if (line.startsWith("+")) newLines.push(text);
499
+ else {
500
+ oldLines.push(text);
501
+ newLines.push(text);
502
+ }
503
+ }
504
+ diffs.push({
505
+ path,
506
+ oldText: oldLines.length > 0 ? oldLines.join("\n") : null,
507
+ newText: newLines.join("\n")
508
+ });
509
+ }
510
+ return diffs;
511
+ }
512
+ /** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */
513
+ function isFileDiff(value) {
514
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
515
+ const { path, oldText, newText } = value;
516
+ return typeof path === "string" && (oldText === null || typeof oldText === "string") && typeof newText === "string";
517
+ }
518
+ /**
519
+ * Narrow opaque live or replayed result metadata to non-empty file diffs. Malformed metadata
520
+ * returns `undefined` so presentation can fall back instead of throwing during replay.
521
+ * @param meta - result metadata.
522
+ * @returns validated hunks, or `undefined` for absent or malformed data.
523
+ */
524
+ function diffsFromMeta(meta) {
525
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
526
+ const diffs = meta.diffs;
527
+ if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return void 0;
528
+ return diffs;
529
+ }
530
+ //#endregion
531
+ //#region lib/types/error.js
532
+ /**
533
+ * Model-facing remediation for guarded-mutation failures. The provider's
534
+ * `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but
535
+ * not the only correct recovery (re-read / read the file), so this package
536
+ * appends the remedy at the model boundary; provider messages stay
537
+ * machine-oriented and unchanged.
538
+ * @module @hydraharness/harness-tool-fs/src/error
539
+ */
540
+ /** The remedy appended to each remediable failure code's message. */
541
+ const REMEDIES = {
542
+ FS_STALE_VERSION: "re-read the file, then retry",
543
+ FS_NOT_OBSERVED: "read the file, then retry"
544
+ };
545
+ /**
546
+ * Append the correct recovery instruction to a guarded-mutation failure's
547
+ * message. `FS_STALE_VERSION` (the file changed since this session's last
548
+ * observation, including a missing target) recovers only by re-reading;
549
+ * `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError`
550
+ * code is preserved so retry/permission/UI layers keep routing on it, and the
551
+ * original error chains as `cause`. Anything else passes through untouched.
552
+ * @param error - the caught value from a write/edit execution.
553
+ * @returns a remediated `FsError` for the two guarded-mutation codes, else the original value.
554
+ */
555
+ function remediateFsError(error) {
556
+ if (!(error instanceof FsError)) return error;
557
+ const remedy = REMEDIES[error.code];
558
+ if (!remedy) return error;
559
+ return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error });
560
+ }
561
+ //#endregion
562
+ //#region lib/types/write.js
563
+ /**
564
+ * Model-facing full-file write. It obtains an optional intent from the single policy slot, calls
565
+ * `ctx.fs.writeText` without a stat, then records the resulting version; no policy means an
566
+ * unconditional atomic create-or-overwrite.
567
+ * @module @hydraharness/harness-tool-fs/src/write
568
+ */
569
+ /**
570
+ * Validate value constraints the schema DSL can't express: only a non-blank
571
+ * `file_path` — an empty `content` is legitimate (it writes an empty file).
572
+ * @param args - the schema-validated raw tool arguments.
573
+ * @returns the camelCased input; `content` passes through untouched.
574
+ */
575
+ function parseWriteArgs(args) {
576
+ if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
577
+ return {
578
+ filePath: args.file_path,
579
+ content: args.content
580
+ };
581
+ }
582
+ /**
583
+ * Format a write outcome as one model-facing text block body.
584
+ * @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
585
+ * @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
586
+ * @returns the model-facing confirmation envelope (no file content is echoed back).
587
+ */
588
+ function formatWriteOutput(displayPath, outcome) {
589
+ return `<path>${displayPath}</path>
590
+ <type>file</type>
591
+ <content>
592
+ ${outcome.operation === "create" ? "Created" : "Updated"} file
593
+ </content>`;
594
+ }
595
+ /**
596
+ * Register the `write` tool and its system-prompt guidance.
597
+ * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
598
+ * @param sandbox - the shared sandbox-escalation API (advertisement, mode stamping, denial mapping).
599
+ */
600
+ function applyWriteTool(ctx, sandbox) {
601
+ ctx.systemPrompt.section({
602
+ name: "tool:write",
603
+ order: 101,
604
+ text: "Create or replace files with write; read existing files first and use edit for targeted changes. For JSON, supply the task's json_schema; use format:text only for intentional literal text. Before delivery, read back and reconcile status, answer, observations, and blocker with the todo list and final answer."
605
+ });
606
+ ctx.tools.register(defineTool({
607
+ name: "write",
608
+ description: "Create or fully replace a UTF-8 text file.",
609
+ parameters: {
610
+ file_path: {
611
+ type: "string",
612
+ required: true,
613
+ description: "Path to write, resolved by the filesystem backend."
614
+ },
615
+ content: {
616
+ type: "string",
617
+ required: true,
618
+ description: "Full UTF-8 text content to write."
619
+ },
620
+ format: {
621
+ type: "string",
622
+ enum: ["text", "json"],
623
+ description: "Default: json for .json paths, otherwise text. JSON parses and serializes; text preserves literal bytes."
624
+ },
625
+ json_schema: {
626
+ type: "object",
627
+ additionalProperties: true,
628
+ description: "Task JSON Schema (supported tool-schema subset); validates before writing and implies JSON format."
629
+ },
630
+ ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
631
+ },
632
+ output: {
633
+ schema: {
634
+ type: "object",
635
+ additionalProperties: false,
636
+ properties: {
637
+ path: {
638
+ type: "string",
639
+ required: true
640
+ },
641
+ operation: {
642
+ type: "string",
643
+ required: true,
644
+ enum: ["create", "update"]
645
+ },
646
+ before: {
647
+ required: true,
648
+ oneOf: [{ type: "string" }, { type: "null" }]
649
+ },
650
+ after: {
651
+ type: "string",
652
+ required: true
653
+ }
654
+ }
655
+ },
656
+ render: (_args, value) => [{
657
+ type: "text",
658
+ text: formatWriteOutput(value.path, value)
659
+ }],
660
+ presentationMeta: (args, value) => ({ diffs: value.before === null ? [] : computeHunkDiffs(args.file_path, value.before, value.after).map(({ path, oldText, newText }) => ({
661
+ path,
662
+ oldText,
663
+ newText
664
+ })) })
665
+ },
666
+ async execute(args, exec) {
667
+ const input = parseWriteArgs(args);
668
+ const json = args.format === "json" || args.json_schema !== void 0 || args.format !== "text" && /\.json$/iu.test(input.filePath);
669
+ if (args.format === "text" && args.json_schema !== void 0) throw new Error("json_schema requires JSON format");
670
+ if (json) {
671
+ let value;
672
+ try {
673
+ value = JSON.parse(input.content);
674
+ } catch (error) {
675
+ throw new Error("Invalid JSON content: use a JSON serializer and correct escaping before retrying.", { cause: error });
676
+ }
677
+ const schema = args.json_schema ?? {};
678
+ assertSupportedJsonSchema(schema);
679
+ const violations = validateJsonSchemaValue(schema, value);
680
+ if (violations.length > 0) throw new Error(`JSON schema validation failed: ${violations.join("; ")}`);
681
+ input.content = `${JSON.stringify(value, void 0, 2)}\n`;
682
+ }
683
+ const sandboxPolicy = await sandbox.resolvePolicy("write", args, exec);
684
+ const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot));
685
+ const intent = await ctx.waterfall("fs/write-intent", target, exec, () => void 0);
686
+ let outcome;
687
+ try {
688
+ outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy);
689
+ } catch (error) {
690
+ throw remediateFsError(sandbox.mapError(error, sandboxPolicy));
691
+ }
692
+ ctx.emit("fs/observed", target, {
693
+ kind: "present",
694
+ version: outcome.version
695
+ }, exec);
696
+ return {
697
+ path: target.displayPath,
698
+ operation: outcome.operation,
699
+ before: outcome.before,
700
+ after: outcome.after
701
+ };
702
+ },
703
+ presentCall(args) {
704
+ return {
705
+ card: "diff",
706
+ title: `Write ${args.file_path}`,
707
+ diffs: [{
708
+ path: args.file_path,
709
+ oldText: null,
710
+ newText: args.content
711
+ }],
712
+ locations: [{ path: args.file_path }]
713
+ };
714
+ },
715
+ presentResult(args, result) {
716
+ if (result.isError) return void 0;
717
+ const diffs = diffsFromMeta(result.meta) ?? [{
718
+ path: args.file_path,
719
+ oldText: null,
720
+ newText: args.content
721
+ }];
722
+ return {
723
+ card: "diff",
724
+ title: `Write ${args.file_path}`,
725
+ diffs
726
+ };
727
+ }
728
+ }));
729
+ }
730
+ //#endregion
731
+ //#region lib/types/edit.js
732
+ /**
733
+ * Model-facing literal edit, unique-match by default. It obtains an optional guard from the
734
+ * single intent slot, calls `ctx.fs.editText` without a separate stat, then records the observed
735
+ * version; no policy means an unconditional atomic edit.
736
+ * @module @hydraharness/harness-tool-fs/src/edit
737
+ */
738
+ /**
739
+ * Validate value constraints the schema DSL can't express: a non-blank
740
+ * `file_path`, a non-empty `old_string`, and `old_string !== new_string`
741
+ * (an equal pair would be a guaranteed no-op edit).
742
+ * @param args - the schema-validated raw tool arguments.
743
+ * @returns the camelCased input with `replace_all` defaulted to false.
744
+ */
745
+ function parseEditArgs(args) {
746
+ if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
747
+ if (args.old_string.length === 0) throw new Error("old_string must be a non-empty string");
748
+ if (args.old_string === args.new_string) throw new Error("old_string and new_string must differ");
749
+ return {
750
+ filePath: args.file_path,
751
+ oldString: args.old_string,
752
+ newString: args.new_string,
753
+ replaceAll: args.replace_all ?? false
754
+ };
755
+ }
756
+ /**
757
+ * Format an edit success (single-match or replace-all) as a Claude-style model-facing message.
758
+ * @param displayPath - the backend-resolved path shown to the model.
759
+ * @param replaceAll - selects the all-occurrences wording over the single-replacement one.
760
+ * @returns the confirmation sentence the model sees as the tool result.
761
+ */
762
+ function formatEditOutput(displayPath, replaceAll) {
763
+ return replaceAll ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` : `The file ${displayPath} has been updated successfully.`;
764
+ }
765
+ /**
766
+ * Register the `edit` tool and its system-prompt guidance.
767
+ * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
768
+ * @param sandbox - the shared sandbox-escalation API (advertisement, mode stamping, denial mapping).
769
+ */
770
+ function applyEditTool(ctx, sandbox) {
771
+ ctx.systemPrompt.section({
772
+ name: "tool:edit",
773
+ order: 102,
774
+ text: "Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session."
775
+ });
776
+ ctx.tools.register(defineTool({
777
+ name: "edit",
778
+ description: "Edit an existing UTF-8 text file by replacing literal text.",
779
+ parameters: {
780
+ file_path: {
781
+ type: "string",
782
+ required: true,
783
+ description: "Path to edit, resolved by the filesystem backend."
784
+ },
785
+ old_string: {
786
+ type: "string",
787
+ required: true,
788
+ description: "Literal text to replace. Must match exactly."
789
+ },
790
+ new_string: {
791
+ type: "string",
792
+ required: true,
793
+ description: "Literal replacement text. Use an empty string to delete the match."
794
+ },
795
+ replace_all: {
796
+ type: "boolean",
797
+ description: "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
798
+ },
799
+ ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
800
+ },
801
+ output: {
802
+ schema: {
803
+ type: "object",
804
+ additionalProperties: false,
805
+ properties: {
806
+ path: {
807
+ type: "string",
808
+ required: true
809
+ },
810
+ before: {
811
+ type: "string",
812
+ required: true
813
+ },
814
+ after: {
815
+ type: "string",
816
+ required: true
817
+ }
818
+ }
819
+ },
820
+ render: (args, value) => [{
821
+ type: "text",
822
+ text: formatEditOutput(value.path, args.replace_all ?? false)
823
+ }],
824
+ presentationMeta: (args, value) => ({ diffs: computeHunkDiffs(args.file_path, value.before, value.after).map(({ path, oldText, newText }) => ({
825
+ path,
826
+ oldText,
827
+ newText
828
+ })) })
829
+ },
830
+ async execute(args, exec) {
831
+ const input = parseEditArgs(args);
832
+ const sandboxPolicy = await sandbox.resolvePolicy("edit", args, exec);
833
+ const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot));
834
+ let outcome;
835
+ try {
836
+ const intent = await ctx.waterfall("fs/edit-intent", target, exec, () => void 0);
837
+ outcome = await ctx.fs.editText(target, {
838
+ oldString: input.oldString,
839
+ newString: input.newString,
840
+ replaceAll: input.replaceAll
841
+ }, intent, exec.signal, sandboxPolicy);
842
+ } catch (error) {
843
+ throw remediateFsError(sandbox.mapError(error, sandboxPolicy));
844
+ }
845
+ ctx.emit("fs/observed", target, {
846
+ kind: "present",
847
+ version: outcome.version
848
+ }, exec);
849
+ return {
850
+ path: target.displayPath,
851
+ before: outcome.before,
852
+ after: outcome.after
853
+ };
854
+ },
855
+ presentCall(args) {
856
+ return {
857
+ card: "diff",
858
+ title: `Edit ${args.file_path}`,
859
+ diffs: [{
860
+ path: args.file_path,
861
+ oldText: args.old_string || null,
862
+ newText: args.new_string
863
+ }],
864
+ locations: [{ path: args.file_path }]
865
+ };
866
+ },
867
+ presentResult(args, result) {
868
+ if (result.isError) return void 0;
869
+ const diffs = diffsFromMeta(result.meta);
870
+ if (diffs === void 0) return void 0;
871
+ return {
872
+ card: "diff",
873
+ title: `Edit ${args.file_path}`,
874
+ diffs
875
+ };
876
+ }
877
+ }));
878
+ }
879
+ //#endregion
880
+ //#region lib/types/read-image.js
881
+ /**
882
+ * The model-facing `read_image` tool: reads a PNG/JPEG/WebP/GIF file, durably
883
+ * commits its bytes through the attachment service (the same lifecycle as a
884
+ * user-uploaded image), and returns an image block so the image enters model
885
+ * context from the next request onward.
886
+ *
887
+ * The route gate is deliberately stricter than the host upload preflight: a
888
+ * tool result enters durable session history, so emitting an image on a route
889
+ * that cannot carry it would break that route's continuation. Unknown
890
+ * capability therefore refuses instead of relying on the adapter guard.
891
+ * @module @hydraharness/harness-tool-fs/src/read-image
892
+ */
893
+ /** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */
894
+ const IMAGE_EXTENSIONS = {
895
+ ".png": "image/png",
896
+ ".jpg": "image/jpeg",
897
+ ".jpeg": "image/jpeg",
898
+ ".webp": "image/webp",
899
+ ".gif": "image/gif"
900
+ };
901
+ /**
902
+ * Map a model-supplied path to its declared image media type by extension.
903
+ * @param filePath - the raw `file_path` argument (not yet resolved).
904
+ * @returns the declared media type, or undefined when the path does not claim an image.
905
+ */
906
+ function imageMediaTypeForPath(filePath) {
907
+ return IMAGE_EXTENSIONS[extname(filePath).toLowerCase()];
908
+ }
909
+ /**
910
+ * Enforce the strict image-capability gate for the calling route. Resolves the
911
+ * session's latest routed provider/model (request header config, then agent
912
+ * options) and requires the exact resolved route to declare `image` input explicitly.
913
+ * @param ctx - the plugin context used to resolve the optional `llm` service.
914
+ * @param exec - the tool-execution context supplying the calling agent.
915
+ * @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages.
916
+ */
917
+ async function assertImageCapableRoute(ctx, exec, requestedPath) {
918
+ const routed = exec.agent?.session.requestHeader()?.config;
919
+ const provider = routed?.provider ?? exec.agent?.options.provider;
920
+ const model = routed?.model ?? exec.agent?.options.model;
921
+ const llm = ctx.get("llm");
922
+ if (provider === void 0 || model === void 0 || llm === void 0) throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`);
923
+ const active = await llm.resolveModelInfo(provider, model, exec.signal);
924
+ if (active.inputModalities === void 0 || !active.inputModalities.includes("image")) throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`);
925
+ }
926
+ /**
927
+ * Re-brand a canonical image outcome into the durable attachment reference an
928
+ * `ImageBlock` carries.
929
+ * @param image - the canonical image metadata from the output schema.
930
+ * @returns the branded attachment reference.
931
+ */
932
+ function imageRefFromValue(image) {
933
+ return {
934
+ attachmentId: AttachmentId(image.attachmentId),
935
+ mediaType: image.mediaType,
936
+ bytes: image.bytes,
937
+ width: image.width,
938
+ height: image.height,
939
+ ...image.name === void 0 ? {} : { name: image.name }
940
+ };
941
+ }
942
+ /**
943
+ * Format an image read as the model-facing envelope beside its image block.
944
+ * @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
945
+ * @param image - the canonical image metadata to summarize.
946
+ * @returns the model-facing envelope; the image itself rides the adjacent image block.
947
+ */
948
+ function formatImageReadOutput(displayPath, image) {
949
+ return `<path>${displayPath}</path>
950
+ <type>image</type>
951
+ <content>
952
+ ${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes
953
+ </content>`;
954
+ }
955
+ /**
956
+ * Project one canonical image read into its model-facing envelope and image.
957
+ * @param value - the canonical image-read outcome.
958
+ * @returns the two content blocks used by native and nested dispatches.
959
+ */
960
+ function imageReadContent(value) {
961
+ return [{
962
+ type: "text",
963
+ text: formatImageReadOutput(value.path, value.image)
964
+ }, {
965
+ type: "image",
966
+ attachment: imageRefFromValue(value.image)
967
+ }];
968
+ }
969
+ /**
970
+ * Register the `read_image` tool into the given context. The composing plugin
971
+ * owns the attachments gate: `src/index.ts` calls this inside
972
+ * `ctx.inject(['attachments'], …)` so the tool exists only while a durable
973
+ * store is mounted. Execution still re-checks `ctx.get('attachments')` for
974
+ * direct callers and gates on the calling route's declared image input.
975
+ * @param ctx - the registration scope; execution uses its `fs` service plus
976
+ * the optional `attachments`/`llm` services.
977
+ */
978
+ function applyReadImageTool(ctx) {
979
+ ctx.tools.register(defineTool({
980
+ name: "read_image",
981
+ description: "Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.",
982
+ parameters: { file_path: {
983
+ type: "string",
984
+ required: true,
985
+ description: "Path to the image file, resolved by the filesystem backend."
986
+ } },
987
+ output: {
988
+ schema: {
989
+ type: "object",
990
+ additionalProperties: false,
991
+ properties: {
992
+ path: {
993
+ type: "string",
994
+ required: true
995
+ },
996
+ image: {
997
+ type: "object",
998
+ additionalProperties: false,
999
+ required: true,
1000
+ properties: {
1001
+ attachmentId: {
1002
+ type: "string",
1003
+ required: true
1004
+ },
1005
+ mediaType: {
1006
+ type: "string",
1007
+ enum: [
1008
+ "image/png",
1009
+ "image/jpeg",
1010
+ "image/webp",
1011
+ "image/gif"
1012
+ ],
1013
+ required: true
1014
+ },
1015
+ bytes: {
1016
+ type: "integer",
1017
+ required: true
1018
+ },
1019
+ width: {
1020
+ type: "integer",
1021
+ required: true
1022
+ },
1023
+ height: {
1024
+ type: "integer",
1025
+ required: true
1026
+ },
1027
+ name: { type: "string" }
1028
+ }
1029
+ }
1030
+ }
1031
+ },
1032
+ render: (_args, value) => imageReadContent(value)
1033
+ },
1034
+ isConcurrencySafe: () => true,
1035
+ async execute(args, exec) {
1036
+ if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
1037
+ const mediaType = imageMediaTypeForPath(args.file_path);
1038
+ if (mediaType === void 0) throw new Error(`cannot read "${args.file_path}": read_image only accepts PNG/JPEG/WebP/GIF paths`);
1039
+ const attachments = ctx.get("attachments");
1040
+ if (attachments === void 0) throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`);
1041
+ if (!attachments.imageLimits.mediaTypes.includes(mediaType)) throw new Error(`cannot read "${args.file_path}": ${mediaType} images are not accepted by this deployment`);
1042
+ await assertImageCapableRoute(ctx, exec, args.file_path);
1043
+ const { target, info } = await resolveRegularReadTarget(ctx, exec, args.file_path);
1044
+ const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes);
1045
+ const data = await ctx.fs.readBytes(target, exec.signal, byteCap);
1046
+ let ref;
1047
+ try {
1048
+ ref = await attachments.saveImage({
1049
+ data,
1050
+ mediaType,
1051
+ name: basename(target.displayPath)
1052
+ });
1053
+ } catch (error) {
1054
+ if (!(error instanceof AttachmentError)) throw error;
1055
+ if (error.code === "IMAGE_DIMENSION_TOO_LARGE") throw new Error(`cannot read "${target.displayPath}": at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, { cause: error });
1056
+ if (error.code === "IMAGE_TOO_MANY_PIXELS") throw new Error(`cannot read "${target.displayPath}": the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, { cause: error });
1057
+ if (error.code !== "IMAGE_TYPE_MISMATCH") throw error;
1058
+ const extension = extname(target.displayPath).toLowerCase();
1059
+ throw new Error(`cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, { cause: error });
1060
+ }
1061
+ ctx.emit("fs/observed", target, {
1062
+ kind: "present",
1063
+ version: info.version
1064
+ }, exec);
1065
+ return {
1066
+ path: target.displayPath,
1067
+ image: {
1068
+ attachmentId: ref.attachmentId,
1069
+ mediaType: ref.mediaType,
1070
+ bytes: ref.bytes,
1071
+ width: ref.width,
1072
+ height: ref.height,
1073
+ ...ref.name === void 0 ? {} : { name: ref.name }
1074
+ }
1075
+ };
1076
+ },
1077
+ presentCall(args) {
1078
+ return {
1079
+ card: "generic",
1080
+ title: `Read image ${args.file_path}`,
1081
+ kind: "read",
1082
+ locations: [{ path: args.file_path }]
1083
+ };
1084
+ }
1085
+ }));
1086
+ }
1087
+ //#endregion
1088
+ //#region lib/types/sandbox.js
1089
+ /**
1090
+ * The sandbox-escalation API shared by the `write` and `edit` tools: the
1091
+ * per-call policy resolution, the advertised escalation fields, and the denial-marker
1092
+ * mapping — all delegating the vocabulary and the fail-closed approval
1093
+ * sequence to `@hydraharness/harness-sandbox` (the same pieces `@hydraharness/harness-tool-bash`
1094
+ * uses), so bash and fs escalate identically. Built ONCE per plugin from
1095
+ * `ctx.fs.sandboxMode` (the capability fact — is a confining backend mounted?)
1096
+ * and shared by both mutating tools.
1097
+ *
1098
+ * @module @hydraharness/harness-tool-fs/sandbox
1099
+ */
1100
+ /**
1101
+ * The filesystem escalation API: advertisement gating, per-call policy
1102
+ * resolution, the one-approved wider retry, and denial-marker mapping. A pure
1103
+ * product of `ctx` at plugin apply time.
1104
+ */
1105
+ var FsSandboxController = class {
1106
+ ctx;
1107
+ /** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */
1108
+ escalationModes;
1109
+ /** Shared per-session policy resolver, required by a confining backend. */
1110
+ policy;
1111
+ constructor(ctx) {
1112
+ this.ctx = ctx;
1113
+ const defaultMode = ctx.fs.sandboxMode;
1114
+ this.escalationModes = defaultMode === void 0 ? [] : ESCALATION_TARGETS;
1115
+ this.policy = defaultMode === void 0 ? void 0 : ctx.get("sandboxPolicy");
1116
+ if (defaultMode !== void 0 && this.policy === void 0) throw new Error("tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing");
1117
+ }
1118
+ /**
1119
+ * The escalation schema fields for a mutating tool's `parameters`. Call it
1120
+ * only under a confining backend (guard on {@link escalationModes}); the
1121
+ * enum pins the closed target vocabulary, the strict-wider check happens per
1122
+ * call at execution.
1123
+ * @returns the two escalation parameter specs.
1124
+ */
1125
+ schemaFields() {
1126
+ return {
1127
+ sandbox_permissions: {
1128
+ type: "string",
1129
+ enum: [...this.escalationModes],
1130
+ description: "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval."
1131
+ },
1132
+ justification: {
1133
+ type: "string",
1134
+ description: "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
1135
+ }
1136
+ };
1137
+ }
1138
+ /**
1139
+ * The policy to stamp onto this mutation: an approved escalation grant (a
1140
+ * strictly wider retry resolved through `ctx.approval` before anything
1141
+ * executes), else the session's standing mode. The calling session's cwd is
1142
+ * always carried as the workspace root. Validates the escalation argument
1143
+ * pairing first.
1144
+ * @param toolName - the mutating tool's name, for the approval audit trail.
1145
+ * @param args - the call's escalation arguments.
1146
+ * @param exec - the tool-execution context (agent, callId, signal).
1147
+ * @returns the policy to pass to the mutation, or undefined for an
1148
+ * unsandboxed backend.
1149
+ */
1150
+ async resolvePolicy(toolName, args, exec) {
1151
+ validateEscalationArgs(args.sandbox_permissions, args.justification);
1152
+ const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} });
1153
+ if (args.sandbox_permissions === void 0 || args.justification === void 0) return standingPolicy;
1154
+ if (this.escalationModes.length === 0) throw new Error("sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)");
1155
+ const policy = standingPolicy;
1156
+ const approvedMode = await approveEscalation({
1157
+ requestedMode: args.sandbox_permissions,
1158
+ justification: args.justification,
1159
+ effectiveMode: policy.mode,
1160
+ subject: "operation"
1161
+ }, {
1162
+ approver: this.ctx.get("approval"),
1163
+ agent: exec.agent,
1164
+ callId: exec.callId,
1165
+ toolName,
1166
+ signal: exec.signal
1167
+ });
1168
+ return {
1169
+ ...policy,
1170
+ mode: approvedMode
1171
+ };
1172
+ }
1173
+ /**
1174
+ * Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes a
1175
+ * `FsError` whose text is the shared `[sandbox: …]` denial marker plus the
1176
+ * same-turn escalation hint, so a policy denial reads identically to bash's
1177
+ * WHILE keeping the structured `FS_SANDBOX_DENIED` code — `ToolRuntime`
1178
+ * populates `result.error` only for `HarnessError` instances, so a plain
1179
+ * `Error` would strip the code retry/observers key off. Any other error
1180
+ * passes through unchanged. A `FS_SANDBOX_DENIED` only arises under a
1181
+ * confining backend, which always advertises the escalation fields, so the
1182
+ * hint always applies here.
1183
+ * @param error - the error thrown by the mutation.
1184
+ * @param policy - the policy stamped onto the call (names the mode in the marker).
1185
+ * @returns the error to throw — the marker `FsError` for a sandbox denial, else the original.
1186
+ */
1187
+ mapError(error, policy) {
1188
+ if (!(error instanceof FsError) || error.code !== "FS_SANDBOX_DENIED") return error;
1189
+ const mode = policy.mode;
1190
+ return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker("operation")}`, "FS_SANDBOX_DENIED", { cause: error });
1191
+ }
1192
+ };
1193
+ //#endregion
1194
+ //#region lib/types/index.js
1195
+ /**
1196
+ * Model-facing read, read_image, write, and edit tools over `ctx.fs`. This package owns schemas, validation,
1197
+ * read windows, formatting, and observation events, never a concrete provider. An optional
1198
+ * event policy supplies mutation guards; without one the tools use unconditional provider calls.
1199
+ * @module @hydraharness/harness-tool-fs
1200
+ */
1201
+ /** Cordis plugin name used by loader diagnostics. */
1202
+ const name = "tool-fs";
1203
+ /** Services required by the filesystem tool suite. */
1204
+ const inject = [
1205
+ "tools",
1206
+ "fs",
1207
+ "systemPrompt"
1208
+ ];
1209
+ const Config = z.object({
1210
+ readLimit: z.number().default(READ_LIMIT),
1211
+ readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
1212
+ readMaxBytes: z.number().default(READ_MAX_BYTES),
1213
+ readStreamMinSize: z.number().default(STREAM_MIN_SIZE)
1214
+ });
1215
+ /** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
1216
+ function assertPositiveInteger(name, value) {
1217
+ if (!Number.isInteger(value) || value < 1) throw new Error(`tool-fs: ${name} must be a positive integer`);
1218
+ }
1219
+ /** Register the full `read`/`write`/`edit` filesystem tool suite, plus `read_image` while `attachments` is mounted. */
1220
+ function apply(ctx, config) {
1221
+ const resolved = config;
1222
+ assertPositiveInteger("readLimit", resolved.readLimit);
1223
+ assertPositiveInteger("readMaxLineLength", resolved.readMaxLineLength);
1224
+ assertPositiveInteger("readMaxBytes", resolved.readMaxBytes);
1225
+ assertPositiveInteger("readStreamMinSize", resolved.readStreamMinSize);
1226
+ applyReadTool(ctx, {
1227
+ limit: resolved.readLimit,
1228
+ maxLineLength: resolved.readMaxLineLength,
1229
+ maxBytes: resolved.readMaxBytes,
1230
+ streamMinSize: resolved.readStreamMinSize
1231
+ });
1232
+ ctx.inject(["attachments"], (imageCtx) => {
1233
+ applyReadImageTool(imageCtx);
1234
+ });
1235
+ const sandbox = new FsSandboxController(ctx);
1236
+ applyWriteTool(ctx, sandbox);
1237
+ applyEditTool(ctx, sandbox);
1238
+ }
1239
+ //#endregion
1240
+ export { Config, apply, inject, name };