@sema-agent/core 2.3.0 → 2.4.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 (61) hide show
  1. package/dist/agents/send-message-tool.d.ts +4 -0
  2. package/dist/agents/send-message-tool.js +37 -24
  3. package/dist/agents/subagent.js +275 -127
  4. package/dist/brain/errors.d.ts +1 -0
  5. package/dist/brain/errors.js +14 -0
  6. package/dist/brain/stream-engine.js +3 -3
  7. package/dist/core/context-edit.js +2 -1
  8. package/dist/core/runner/prepare-task.js +20 -1
  9. package/dist/core/runner/tool-output-projection.js +2 -1
  10. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +37 -0
  11. package/dist/core/store-contracts/checkpoint-store-contract.js +195 -0
  12. package/dist/core/store-contracts/contract-harness.d.ts +6 -0
  13. package/dist/core/store-contracts/contract-harness.js +16 -0
  14. package/dist/core/store-contracts/contract-kit-version.d.ts +1 -0
  15. package/dist/core/store-contracts/contract-kit-version.js +2 -0
  16. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -0
  17. package/dist/core/store-contracts/file-snapshot-store-contract.js +126 -0
  18. package/dist/core/store-contracts/mailbox-store-contract.d.ts +6 -0
  19. package/dist/core/store-contracts/mailbox-store-contract.js +193 -0
  20. package/dist/core/store-contracts/session-repo-contract.d.ts +3 -0
  21. package/dist/core/store-contracts/session-repo-contract.js +36 -0
  22. package/dist/core/store-contracts/tool-result-store-contract.d.ts +3 -0
  23. package/dist/core/store-contracts/tool-result-store-contract.js +35 -0
  24. package/dist/core/task-notification.d.ts +2 -0
  25. package/dist/core/task-registry-agent.d.ts +4 -0
  26. package/dist/core/task-registry-agent.js +13 -0
  27. package/dist/core/task-registry-monitor.js +6 -6
  28. package/dist/core/task-registry-shared.d.ts +8 -2
  29. package/dist/core/task-registry-shared.js +1 -1
  30. package/dist/core/task-registry.d.ts +6 -0
  31. package/dist/core/task-registry.js +48 -4
  32. package/dist/core/tool-result-store.d.ts +3 -2
  33. package/dist/core/tool-result-store.js +12 -4
  34. package/dist/core/trace.d.ts +7 -0
  35. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  36. package/dist/engine/lsp/node-lsp-manager.js +16 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -0
  39. package/dist/orchestration/builtin-workflows.d.ts +1 -1
  40. package/dist/orchestration/builtin-workflows.js +11 -2
  41. package/dist/orchestration/workflow-governance.d.ts +6 -1
  42. package/dist/orchestration/workflow-governance.js +24 -4
  43. package/dist/orchestration/workflow-primitives.js +7 -1
  44. package/dist/orchestration/workflow.d.ts +1 -0
  45. package/dist/orchestration/workflow.js +31 -2
  46. package/dist/tools/fs/fs-bash.d.ts +7 -1
  47. package/dist/tools/fs/fs-bash.js +51 -20
  48. package/dist/tools/fs/fs-read.js +22 -11
  49. package/dist/tools/fs/fs-search-tools.js +3 -3
  50. package/dist/tools/fs/fs-shared.d.ts +20 -7
  51. package/dist/tools/fs/fs-shared.js +17 -3
  52. package/dist/tools/fs/fs-write.js +4 -4
  53. package/dist/tools/fs/index.d.ts +2 -0
  54. package/dist/tools/fs/index.js +7 -1
  55. package/dist/tools/fs/repo-map.js +2 -2
  56. package/dist/tools/fs/safety.d.ts +10 -0
  57. package/dist/tools/fs/safety.js +15 -1
  58. package/dist/tools/monitor.js +18 -4
  59. package/dist/tools/web.js +6 -2
  60. package/dist/tools/worktree.js +46 -25
  61. package/package.json +1 -1
@@ -5,9 +5,9 @@ import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_ALIASES, TASK_S
5
5
  import { hasBackgroundShell } from "../../core/background-shell.js";
6
6
  import { delimitUntrusted } from "../../core/untrusted-text.js";
7
7
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
8
- import { imageMagicMatches } from "./safety.js";
8
+ import { imageMagicMatches, withinAnyRoot } from "./safety.js";
9
9
  import { ghRateLimitHint } from "./gh-rate-limit.js";
10
- import { BASH_DEFAULT_TIMEOUT_SEC, BASH_MAX_TIMEOUT_SEC, BASH_DEFAULT_TIMEOUT_MS, BASH_MAX_TIMEOUT_MS, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, msTimeoutToSec, } from "./fs-shared.js";
10
+ import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
11
11
  import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly } from "./bash-readonly-classifier.js";
12
12
  export function bashReversibilityProbe(allow) {
13
13
  const allowSet = new Set(allow ?? BASH_READONLY_DEFAULT_ALLOW);
@@ -156,9 +156,15 @@ export function canAutoBackground(command) {
156
156
  return false;
157
157
  return true;
158
158
  }
159
- async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef, detach, execClamp, toolCallId, readOnly) {
160
- let timeout = Math.min(BASH_MAX_TIMEOUT_SEC, Math.max(1, Math.floor(timeoutSec ?? BASH_DEFAULT_TIMEOUT_SEC)));
161
- const requestedSec = timeout;
159
+ function msTimeoutToRequestedSec(timeoutMs) {
160
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
161
+ return undefined;
162
+ return Math.max(1, Math.round(timeoutMs / 1000));
163
+ }
164
+ async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, cwdRef, detach, execClamp, toolCallId, readOnly, containmentRoots) {
165
+ const requestedSec = Math.max(1, Math.floor(timeoutSec ?? caps.defaultSec));
166
+ let timeout = Math.min(caps.maxSec, requestedSec);
167
+ const cappedByMaxTimeout = requestedSec > caps.maxSec;
162
168
  let clampedByDeadline = false;
163
169
  let deadlineExhausted = false;
164
170
  if (execClamp) {
@@ -235,13 +241,16 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
235
241
  ? deadlineExhausted
236
242
  ? `Command was cut off after ${timeout}s — NOT its requested ${requestedSec}s timeout: the task's wall-clock deadline has already been reached, so the command only got a ${timeout}s grace window. The process was killed; output produced before the cutoff is shown below. Do NOT retry this foreground command — there is no time left. Use run_in_background (exempt from this clamp) if the work must continue, or write out your results now.`
237
243
  : `Command was cut off after ${timeout}s — NOT its requested ${requestedSec}s timeout: the task is near its wall-clock deadline, so the foreground time budget was clamped to the ${timeout}s remaining. The process was killed; output produced before the cutoff is shown below. Do NOT retry this foreground command — the next attempt gets even less time. Use run_in_background (exempt from this clamp) if the work must continue, or write out your results now.`
238
- : `Command timed out after ${timeout}s (timeout limit: ${timeout}s). The process was killed; output produced before the cutoff is shown below. Re-running the same command will likely time out again — consider run_in_background for long commands, a narrower command, or resuming from the partial progress below.`
244
+ : cappedByMaxTimeout
245
+ ? `Command timed out after ${timeout}s — requested ${requestedSec}s, capped at ${caps.maxSec}s (engine ceiling: requests above ${caps.maxSec}s are reduced to it). The process was killed; output produced before the cutoff is shown below. Re-running with a larger timeout gets the same ${caps.maxSec}s ceiling — use run_in_background for work that needs longer, a narrower command, or resume from the partial progress below.`
246
+ : `Command timed out after ${timeout}s (timeout limit: ${timeout}s). The process was killed; output produced before the cutoff is shown below. Re-running the same command will likely time out again — consider run_in_background for long commands, a narrower command, or resuming from the partial progress below.`
239
247
  : res.error.code === "aborted"
240
248
  ? `Command was interrupted (aborted) before completion. Output produced before the interrupt is shown below.`
241
249
  : `Command started but its completion could not be observed — a host-side output-stream callback failed mid-run and the process was terminated (${res.error.message}). Output captured before the cut is shown below; its effects up to that point may have landed. Verify before assuming it needs a full re-run.`;
242
250
  const captured = (pStdout ? `--- partial stdout ---\n${pStdout}` : "") +
243
251
  (pStderr ? `${pStdout ? "\n" : ""}--- partial stderr ---\n${pStderr}` : "");
244
- const body = captured.length > 0
252
+ const zeroOutput = captured.length === 0;
253
+ const body = !zeroOutput
245
254
  ? `\n${delimitUntrusted("partial command output", captured)}`
246
255
  : `\n(no output was produced before the cutoff)`;
247
256
  const overflowNote = cutOverflowFile !== undefined ? shellRecoveryHint(cutOverflowFile, readOnly) : "";
@@ -257,12 +266,15 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
257
266
  ? {
258
267
  timedOut: true,
259
268
  timeoutSec: timeout,
260
- ...(clampedByDeadline ? { requestedTimeoutSec: requestedSec, clampedByDeadline: true } : {}),
269
+ ...(clampedByDeadline || cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec } : {}),
270
+ ...(clampedByDeadline ? { clampedByDeadline: true } : {}),
271
+ ...(cappedByMaxTimeout ? { cappedToMaxTimeoutSec: caps.maxSec } : {}),
261
272
  }
262
273
  : res.error.code === "aborted"
263
274
  ? { aborted: true }
264
275
  : { callbackError: true }),
265
276
  },
277
+ ...(zeroOutput ? { isError: true } : {}),
266
278
  };
267
279
  }
268
280
  return {
@@ -291,6 +303,13 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
291
303
  cwdRef.current = captured;
292
304
  }
293
305
  }
306
+ let cwdOutsideNote = "";
307
+ if (cwdRef && containmentRoots !== undefined && containmentRoots.length > 0 && !withinAnyRoot(containmentRoots, cwdRef.current)) {
308
+ const cwdCanon = await env.canonicalPath(cwdRef.current, signal);
309
+ if (!(cwdCanon.ok && withinAnyRoot(containmentRoots, cwdCanon.value))) {
310
+ cwdOutsideNote = `NOTE: working directory is now outside the task root(s) (${cwdRef.current}); relative paths in the structured file tools (Read/Edit/Write/Grep/Glob) will resolve there and may be refused. Pass absolute in-root paths, or \`cd\` back inside.`;
311
+ }
312
+ }
294
313
  const clippedStdout = clipShellOutput(stdout);
295
314
  const clippedStderr = clipShellOutput(stderr);
296
315
  const stdoutImage = dataUriImageFromStdout(stdout);
@@ -300,7 +319,7 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
300
319
  return {
301
320
  content: [
302
321
  { type: "image", data: stdoutImage.data, mimeType: stdoutImage.mime },
303
- { type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}` },
322
+ { type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}${cwdOutsideNote ? `\n${cwdOutsideNote}` : ""}` },
304
323
  ],
305
324
  details: { type: "bash", stdout: "[image data]", stderr: clippedStderr, exitCode, isImage: true, ...(imgOverflowFile !== undefined ? { output_file: imgOverflowFile } : {}) },
306
325
  };
@@ -313,6 +332,8 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
313
332
  parts.push(`--- stderr ---\n${clippedStderr}`);
314
333
  if (overflowNote)
315
334
  parts.push(overflowNote.trim());
335
+ if (cwdOutsideNote)
336
+ parts.push(cwdOutsideNote);
316
337
  return {
317
338
  content: parts.join("\n"),
318
339
  details: {
@@ -325,7 +346,7 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
325
346
  },
326
347
  };
327
348
  }
328
- function bashDescription(coAuthor, bgNotifies = false, bgRetained = false) {
349
+ function bashDescription(coAuthor, caps, bgNotifies = false, bgRetained = false) {
329
350
  const coAuthorLines = coAuthor === false
330
351
  ? "- Follow the deployment's commit-message conventions."
331
352
  : `- End git commit messages with:\nCo-Authored-By: ${coAuthor}`;
@@ -334,7 +355,7 @@ function bashDescription(coAuthor, bgNotifies = false, bgRetained = false) {
334
355
  - Working directory persists between calls, but prefer absolute paths — a \`cd\` carries over and moves the base for every later command. Shell state (env vars, functions) does not persist; the shell is re-initialized each call, starting at the configured root.
335
356
  - IMPORTANT: Avoid using this tool to run \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user.
336
357
  - File search by name: use the Glob tool (NOT \`find\` or \`ls\`); content search: use the Grep tool (NOT \`grep\`/\`rg\` in the shell).
337
- - \`timeout\` is in milliseconds: default ${BASH_DEFAULT_TIMEOUT_MS}, max ${BASH_MAX_TIMEOUT_MS}.
358
+ - \`timeout\` is in milliseconds: default ${caps.defaultMs}, max ${caps.maxMs}.
338
359
  - \`run_in_background\` runs the command detached: it keeps running across turns and ${bgNotifies ? "re-invokes you when it exits" : "you read its output later with TaskOutput(task_id)"}. No \`&\` needed.${bgRetained ? "" : " Background processes do NOT survive the session — they are reaped when the task ends. A deliverable that must stay alive afterwards (a server, a daemon) needs a self-detaching FOREGROUND start instead: run `nohup cmd >log 2>&1 &` as a normal foreground command (portable; setsid does not exist on macOS), or use a service manager."}
339
360
 
340
361
  # Git
@@ -348,15 +369,17 @@ ${coAuthorLines}
348
369
  - Treat evidence of a deployment restriction — "Operation not permitted", "Read-only file system", a denied path, or a network failure — as a boundary, not a bug: adjust within the allowed scope or report the limit; don't reach for a destructive or privilege-escalating workaround.`;
349
370
  }
350
371
  export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = { current: rootCanonical }, taskOpts = {}) {
372
+ const timeoutCaps = resolveBashTimeoutCaps(taskOpts);
373
+ const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
351
374
  const bgNotifies = taskOpts.taskRegistry !== undefined && taskOpts.onTaskNotification !== undefined;
352
375
  const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
353
376
  return defineTool({
354
377
  name: "Bash",
355
378
  contract: { contractId: "core.bash@1", implementationRevision: "1" },
356
- description: bashDescription(coAuthor, bgNotifies, bgRetained),
379
+ description: bashDescription(coAuthor, timeoutCaps, bgNotifies, bgRetained),
357
380
  parameters: Type.Object({
358
381
  command: Type.String({ description: "The command to execute" }),
359
- timeout: Type.Optional(Type.Number({ description: `Optional timeout in milliseconds (max ${BASH_MAX_TIMEOUT_MS})` })),
382
+ timeout: Type.Optional(Type.Number({ description: `Optional timeout in milliseconds (max ${timeoutCaps.maxMs})` })),
360
383
  description: Type.Optional(Type.String({
361
384
  description: 'Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.\n' +
362
385
  "\n" +
@@ -406,7 +429,12 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
406
429
  const appliedTimeoutSec = typeof bgCaps.defaultBgTimeoutSec === "number" && typeof bgCaps.maxBgTimeoutSec === "number"
407
430
  ? Math.min(requestedTimeoutSec ?? bgCaps.defaultBgTimeoutSec, bgCaps.maxBgTimeoutSec)
408
431
  : undefined;
409
- const budgetNote = appliedTimeoutSec !== undefined ? ` Time budget: auto-terminates if still running after ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).` : "";
432
+ const bgCappedByMax = appliedTimeoutSec !== undefined && requestedTimeoutSec !== undefined && typeof bgCaps.maxBgTimeoutSec === "number" && requestedTimeoutSec > bgCaps.maxBgTimeoutSec;
433
+ const budgetNote = appliedTimeoutSec !== undefined
434
+ ? bgCappedByMax
435
+ ? ` Time budget: requested ${requestedTimeoutSec}s, capped at ${bgCaps.maxBgTimeoutSec}s (env ceiling: requests above ${bgCaps.maxBgTimeoutSec}s are reduced to it) — auto-terminates if still running after ${appliedTimeoutSec}s.`
436
+ : ` Time budget: auto-terminates if still running after ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).`
437
+ : "";
410
438
  const lifetimeNote = bgCaps.retainBackgroundProcesses === true
411
439
  ? ""
412
440
  : ` NOTE: background processes do NOT survive the session (reaped at task end). If this is a deliverable service that must stay alive afterwards, host it with a FOREGROUND command instead: \`nohup cmd >log 2>&1 &\` (self-detaching — survives the session).`;
@@ -462,7 +490,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
462
490
  ? ` Output file: ${outputFile} (full output is appended there — Read it any time).`
463
491
  : ` Use TaskOutput("${taskId}") to check interim output.`;
464
492
  if (taskOpts.oneShot === true && onNotify !== undefined) {
465
- return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput("${taskId}", { block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget. TaskStop("${taskId}") to stop.` +
493
+ return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget. TaskStop("${taskId}") to stop.` +
466
494
  budgetNote +
467
495
  lifetimeNote);
468
496
  }
@@ -560,7 +588,8 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
560
588
  : `Use TaskOutput("${taskId}") to read its output. `) +
561
589
  `TaskStop("${taskId}") to stop it. ` +
562
590
  (taskOpts.oneShot === true && onNotify !== undefined
563
- ? `This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput("${taskId}", { block: true }).`
591
+ ?
592
+ `This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }).`
564
593
  : onNotify !== undefined
565
594
  ? `You will be notified when it completes — do not poll.`
566
595
  : `Poll TaskOutput until its status is no longer "running".`) +
@@ -574,7 +603,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
574
603
  : undefined;
575
604
  let res;
576
605
  try {
577
- res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, cwdRef, detachChain, taskOpts.execClamp, ctx.toolCallId, false);
606
+ res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, cwdRef, detachChain, taskOpts.execClamp, ctx.toolCallId, false, [rootCanonical, ...(taskOpts.additionalRoots ?? [])]);
578
607
  }
579
608
  finally {
580
609
  taskOpts.detachHub?.gc(ctx.toolCallId);
@@ -591,7 +620,9 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
591
620
  },
592
621
  });
593
622
  }
594
- export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
623
+ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, timeoutOpts) {
624
+ const timeoutCaps = resolveBashTimeoutCaps(timeoutOpts);
625
+ const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
595
626
  const sample = [...allow].slice(0, 6).join(", ");
596
627
  return defineTool({
597
628
  name: "Bash",
@@ -601,7 +632,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
601
632
  "allowlisted commands run. Still subject to the deployment's approval policy.",
602
633
  parameters: Type.Object({
603
634
  command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
604
- timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${BASH_DEFAULT_TIMEOUT_MS}, max ${BASH_MAX_TIMEOUT_MS}).` })),
635
+ timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${timeoutCaps.defaultMs}, max ${timeoutCaps.maxMs}).` })),
605
636
  }),
606
637
  effect: "read",
607
638
  execute: async (args, ctx) => {
@@ -609,7 +640,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp) {
609
640
  const reason = coarseReadonlyCheck(command, allow);
610
641
  if (reason)
611
642
  return errorResult(`Error (Bash): ${reason}`);
612
- return runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
643
+ return runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
613
644
  },
614
645
  });
615
646
  }
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { sha256, resolveKey, violationText, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
3
+ import { sha256, resolveKey, violationText, violationDetails, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
4
4
  import { decodeTextBytes } from "./encoding.js";
5
5
  import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
6
6
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js";
@@ -38,8 +38,8 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
38
38
  "- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.",
39
39
  parameters: Type.Object({
40
40
  ...FILE_PATH_PARAMS,
41
- offset: Type.Optional(Type.Number({ description: "The line number to start reading from. Only provide if the file is too large to read at once (1-based; default 1)." })),
42
- limit: Type.Optional(Type.Number({ description: "The number of lines to read. Only provide if the file is too large to read at once. (Default: the whole file, bounded by the output-token cap.)" })),
41
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "The line number to start reading from. Only provide if the file is too large to read at once (1-based; default 1)." })),
42
+ limit: Type.Optional(Type.Integer({ minimum: 1, description: "The number of lines to read. Only provide if the file is too large to read at once. (Default: the whole file, bounded by the output-token cap.)" })),
43
43
  pages: Type.Optional(Type.String({ description: `Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.` })),
44
44
  }),
45
45
  effect: "read",
@@ -48,9 +48,13 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
48
48
  const path = fileArgPath(args);
49
49
  if (path === undefined)
50
50
  return errorResult(`Error (Read): file_path is required.`);
51
+ const fileUnchangedResult = (text, reason, startLine, endLine, totalLines) => ({
52
+ content: text,
53
+ details: { type: "file_unchanged", reason, file: { filePath: path, startLine, endLine, totalLines } },
54
+ });
51
55
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx));
52
56
  if (!r.ok)
53
- return errorResult(violationText("Read", r.violation));
57
+ return errorResult(violationText("Read", r.violation), violationDetails(r.violation));
54
58
  const isNb = isNotebookPath(r.key);
55
59
  const imageMime = imageMimeForRead(r.key);
56
60
  if (imageMime !== undefined) {
@@ -116,20 +120,23 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
116
120
  if (r.key.toLowerCase().endsWith(".pdf")) {
117
121
  return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities));
118
122
  }
119
- if (hasBinaryExtension(r.key)) {
120
- return errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
121
- }
123
+ const binaryExt = hasBinaryExtension(r.key);
124
+ const binaryExtRefusal = () => errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
122
125
  const info = await env.fileInfo(r.key, ctx.signal);
123
126
  if (info.ok) {
124
127
  if (info.value.kind === "directory") {
125
128
  return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
126
129
  }
127
130
  if (info.value.size > SLICED_READ_MAX_BYTES) {
131
+ if (binaryExt)
132
+ return binaryExtRefusal();
128
133
  return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
129
134
  `(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
130
135
  `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
131
136
  }
132
137
  if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) {
138
+ if (binaryExt)
139
+ return binaryExtRefusal();
133
140
  return errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
134
141
  }
135
142
  }
@@ -143,11 +150,15 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
143
150
  return errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`);
144
151
  const readSize = readBin.value.byteLength;
145
152
  if (readSize > SLICED_READ_MAX_BYTES) {
153
+ if (binaryExt)
154
+ return binaryExtRefusal();
146
155
  return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
147
156
  `(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
148
157
  `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
149
158
  }
150
159
  if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) {
160
+ if (binaryExt)
161
+ return binaryExtRefusal();
151
162
  return errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
152
163
  }
153
164
  if (pdfMagicMatches(readBin.value)) {
@@ -194,10 +205,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
194
205
  }
195
206
  const prevNb = state.get(r.key);
196
207
  if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) {
197
- return seededFileUnchangedReminder(r.key);
208
+ return fileUnchangedResult(seededFileUnchangedReminder(r.key), "already-in-context", 1, total, total);
198
209
  }
199
210
  if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
200
- return `[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`;
211
+ return fileUnchangedResult(`[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`, "unchanged-since-last-read", 1, total, total);
201
212
  }
202
213
  state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now() });
203
214
  const bodyBlocks = rendered.blocks.length > 0 ? rendered.blocks : [{ type: "text", text: "[notebook has 0 cells]" }];
@@ -251,10 +262,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
251
262
  const truncated = start > 1 || end < total || pageMarker !== undefined;
252
263
  const prev = state.get(r.key);
253
264
  if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
254
- return seededFileUnchangedReminder(r.key);
265
+ return fileUnchangedResult(seededFileUnchangedReminder(r.key), "already-in-context", 1, total, total);
255
266
  }
256
267
  if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
257
- return `[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`;
268
+ return fileUnchangedResult(`[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`, "unchanged-since-last-read", start, end, total);
258
269
  }
259
270
  state.set(r.key, {
260
271
  hash,
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { resolveKey, violationText } from "./safety.js";
3
+ import { resolveKey, violationText, violationDetails } from "./safety.js";
4
4
  import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, invalidGlobTokens } from "./search.js";
5
5
  export function createGrepTool(env, rootCanonical, additionalRoots) {
6
6
  return defineTool({
@@ -63,7 +63,7 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
63
63
  if (a.path !== undefined) {
64
64
  const r = await resolveKey(env, rootCanonical, a.path, ctx.signal, rootCanonical, additionalRoots);
65
65
  if (!r.ok)
66
- return errorResult(violationText("Grep", r.violation));
66
+ return errorResult(violationText("Grep", r.violation), violationDetails(r.violation));
67
67
  scoped = r.key;
68
68
  }
69
69
  const grepRun = await runGrepDetailed(env, rootCanonical, {
@@ -182,7 +182,7 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
182
182
  if (path !== undefined) {
183
183
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, rootCanonical, additionalRoots);
184
184
  if (!r.ok)
185
- return errorResult(violationText("Glob", r.violation));
185
+ return errorResult(violationText("Glob", r.violation), violationDetails(r.violation));
186
186
  scoped = r.key;
187
187
  }
188
188
  const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import type { ExecutionEnv } from "../../internal/harness-types.js";
3
- import { type ReadFileState } from "./safety.js";
3
+ import { type FsViolation, type ReadFileState } from "./safety.js";
4
4
  import { type DecodedTextFile } from "./encoding.js";
5
5
  import { type ImageDownsampler } from "../../core/mcp.js";
6
6
  export declare const MAX_READ_BYTES: number;
@@ -15,11 +15,7 @@ export declare function decodeEditBytes(bytes: Uint8Array, path: string): {
15
15
  message: string;
16
16
  };
17
17
  export declare function persistedTextOf(encoded: string | Uint8Array): string;
18
- export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: {
19
- code: string;
20
- message: string;
21
- partialView?: boolean;
22
- }, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
18
+ export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: Pick<FsViolation, "code" | "message" | "partialView">, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
23
19
  export declare const MAX_IMAGE_READ_BYTES: number;
24
20
  export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
25
21
  export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
@@ -30,6 +26,20 @@ export declare const BASH_DEFAULT_TIMEOUT_SEC = 120;
30
26
  export declare const BASH_MAX_TIMEOUT_SEC = 600;
31
27
  export declare const BASH_DEFAULT_TIMEOUT_MS: number;
32
28
  export declare const BASH_MAX_TIMEOUT_MS: number;
29
+ export declare function resolveBashTimeoutCaps(opts?: {
30
+ bashDefaultTimeoutMs?: number;
31
+ bashMaxTimeoutMs?: number;
32
+ }): {
33
+ defaultMs: number;
34
+ maxMs: number;
35
+ };
36
+ export declare function bashTimeoutCapsSec(caps: {
37
+ defaultMs: number;
38
+ maxMs: number;
39
+ }): {
40
+ defaultSec: number;
41
+ maxSec: number;
42
+ };
33
43
  export declare function bashMaxOutputChars(): number;
34
44
  export declare const FILE_PATH_PARAMS: {
35
45
  file_path: Type.TOptional<Type.TString>;
@@ -40,7 +50,10 @@ export declare function writeShellOverflowFile(env: ExecutionEnv, stdout: string
40
50
  export declare function shellRecoveryHint(path: string, readOnly: boolean | undefined): string;
41
51
  export declare const FILE_STATE_TRAILER = " (file state is current in your context \u2014 no need to Read it back)";
42
52
  export declare const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
43
- export declare function msTimeoutToSec(timeoutMs: number | undefined): number;
53
+ export declare function msTimeoutToSec(timeoutMs: number | undefined, caps?: {
54
+ defaultMs: number;
55
+ maxMs: number;
56
+ }): number;
44
57
  export declare function ipynbRedirect(toolName: string, path: string): string | undefined;
45
58
  export declare function countLines(s: string): number;
46
59
  export declare function seededFileUnchangedReminder(filePath: string): string;
@@ -51,6 +51,19 @@ export const BASH_DEFAULT_TIMEOUT_SEC = 120;
51
51
  export const BASH_MAX_TIMEOUT_SEC = 600;
52
52
  export const BASH_DEFAULT_TIMEOUT_MS = BASH_DEFAULT_TIMEOUT_SEC * 1000;
53
53
  export const BASH_MAX_TIMEOUT_MS = BASH_MAX_TIMEOUT_SEC * 1000;
54
+ function validTimeoutMs(n) {
55
+ return n !== undefined && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
56
+ }
57
+ export function resolveBashTimeoutCaps(opts) {
58
+ const defaultMs = validTimeoutMs(opts?.bashDefaultTimeoutMs) ??
59
+ validTimeoutMs(Number(process.env.BASH_DEFAULT_TIMEOUT_MS)) ??
60
+ BASH_DEFAULT_TIMEOUT_MS;
61
+ const maxMs = Math.max(validTimeoutMs(opts?.bashMaxTimeoutMs) ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS)) ?? BASH_MAX_TIMEOUT_MS, defaultMs);
62
+ return { defaultMs, maxMs };
63
+ }
64
+ export function bashTimeoutCapsSec(caps) {
65
+ return { defaultSec: Math.max(1, Math.round(caps.defaultMs / 1000)), maxSec: Math.max(1, Math.round(caps.maxMs / 1000)) };
66
+ }
54
67
  const BASH_DEFAULT_MAX_OUTPUT_CHARS = 30_000;
55
68
  const BASH_MAX_OUTPUT_CHARS_CEILING = 150_000;
56
69
  export function bashMaxOutputChars() {
@@ -84,10 +97,11 @@ export function shellRecoveryHint(path, readOnly) {
84
97
  }
85
98
  export const FILE_STATE_TRAILER = " (file state is current in your context — no need to Read it back)";
86
99
  export const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
87
- export function msTimeoutToSec(timeoutMs) {
100
+ export function msTimeoutToSec(timeoutMs, caps = resolveBashTimeoutCaps()) {
101
+ const { defaultSec, maxSec } = bashTimeoutCapsSec(caps);
88
102
  if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
89
- return BASH_DEFAULT_TIMEOUT_SEC;
90
- return Math.min(BASH_MAX_TIMEOUT_SEC, Math.max(1, Math.round(timeoutMs / 1000)));
103
+ return defaultSec;
104
+ return Math.min(maxSec, Math.max(1, Math.round(timeoutMs / 1000)));
91
105
  }
92
106
  export function ipynbRedirect(toolName, path) {
93
107
  if (path.toLowerCase().endsWith(".ipynb")) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { defineTool, errorResult } from "../../core/tools.js";
4
- import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
4
+ import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
5
5
  import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
6
6
  import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
7
7
  async function gateToolWrite(hook, tool, path, key, content) {
@@ -56,7 +56,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
56
56
  return errorResult(`Error (Edit): file_path is required.`);
57
57
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
58
58
  if (!r.ok)
59
- return errorResult(violationText("Edit", r.violation));
59
+ return errorResult(violationText("Edit", r.violation), violationDetails(r.violation));
60
60
  if (!batch && a.old_string === a.new_string) {
61
61
  return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
62
62
  }
@@ -241,7 +241,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
241
241
  return errorResult(ipynb);
242
242
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
243
243
  if (!r.ok)
244
- return errorResult(violationText("Write", r.violation));
244
+ return errorResult(violationText("Write", r.violation), violationDetails(r.violation));
245
245
  const exists = await env.exists(r.key, ctx.signal);
246
246
  if (!exists.ok)
247
247
  return errorResult(`Error (Write): cannot stat "${path}": ${exists.error.message}`);
@@ -331,7 +331,7 @@ export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additi
331
331
  return errorResult(`Error (NotebookEdit): cell_id is required for replace/delete.`);
332
332
  const r = await resolveKey(env, rootCanonical, notebook_path, ctx.signal, cwdRef?.current, additionalRoots);
333
333
  if (!r.ok)
334
- return errorResult(violationText("NotebookEdit", r.violation));
334
+ return errorResult(violationText("NotebookEdit", r.violation), violationDetails(r.violation));
335
335
  const notRead = requireRead(state, r.key);
336
336
  if (notRead)
337
337
  return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal));
@@ -29,6 +29,8 @@ export interface HandsToolkitOptions {
29
29
  }) => void;
30
30
  detachHub?: import("../../core/tool-detach.js").ToolDetachHub;
31
31
  execClamp?: ExecClampOption;
32
+ bashDefaultTimeoutMs?: number;
33
+ bashMaxTimeoutMs?: number;
32
34
  oneShot?: boolean;
33
35
  autoBackgroundOnTimeout?: boolean;
34
36
  readImageDownsampler?: ReadImageDownsamplerOption;
@@ -35,7 +35,10 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
35
35
  tools.push(createGrepTool(env, rootCanonical, additionalRoots), createGlobTool(env, rootCanonical, additionalRoots), createRepoMapTool(env, rootCanonical, additionalRoots));
36
36
  if (includeShell) {
37
37
  tools.push(readOnly
38
- ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), opts.execClamp)
38
+ ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), opts.execClamp, {
39
+ ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
40
+ ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
41
+ })
39
42
  : createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
40
43
  taskRegistry: opts.taskRegistry,
41
44
  taskOwner: opts.taskOwner,
@@ -46,6 +49,9 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
46
49
  execClamp: opts.execClamp,
47
50
  ...(opts.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: opts.autoBackgroundOnTimeout } : {}),
48
51
  ...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}),
52
+ ...(additionalRoots !== undefined ? { additionalRoots } : {}),
53
+ ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
54
+ ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
49
55
  }));
50
56
  if (!readOnly && mountBackgroundTaskTools && hasBackgroundShell(env)) {
51
57
  const sessionAxis = opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {};
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { resolveKey, violationText } from "./safety.js";
3
+ import { resolveKey, violationText, violationDetails } from "./safety.js";
4
4
  import { buildIgnore, walk, walkIsPartial } from "./search.js";
5
5
  const DEFAULT_MAX_CHARS = 16_000;
6
6
  const DEFAULT_MAX_FILES = 400;
@@ -101,7 +101,7 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
101
101
  if (a.path !== undefined) {
102
102
  const r = await resolveKey(env, rootCanonical, a.path, signal, rootCanonical, additionalRoots);
103
103
  if (!r.ok)
104
- return errorResult(violationText("RepoMap", r.violation));
104
+ return errorResult(violationText("RepoMap", r.violation), violationDetails(r.violation));
105
105
  start = r.key;
106
106
  }
107
107
  const maxChars = Math.max(1, Math.floor(a.max_chars ?? DEFAULT_MAX_CHARS));
@@ -18,6 +18,8 @@ export declare function sha256(content: string): string;
18
18
  export interface FsViolation {
19
19
  code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid";
20
20
  partialView?: true;
21
+ target?: string;
22
+ roots?: readonly string[];
21
23
  message: string;
22
24
  }
23
25
  export declare function isBlockedDevicePath(key: string): boolean;
@@ -43,6 +45,14 @@ export declare function canonicalizeTarget(env: ExecutionEnv, path: string, sign
43
45
  unresolvedSymlink?: true;
44
46
  }>;
45
47
  export declare function violationText(toolName: string, v: FsViolation): string;
48
+ export declare function violationDetails(v: FsViolation): {
49
+ type: "path_not_in_root";
50
+ code: "path_not_in_root";
51
+ target: string;
52
+ roots: readonly string[];
53
+ } | undefined;
54
+ export declare function withinAnyRoot(rootsCanonical: readonly string[], p: string): boolean;
55
+ export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s) and the deployment permits it, use the Bash tool \u2014 it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
46
56
  export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
47
57
  export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
48
58
  export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
@@ -175,7 +175,12 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
175
175
  const roots = [rootCanonical, ...(additionalRootsCanonical ?? [])];
176
176
  return {
177
177
  ok: false,
178
- violation: { code: "path_not_in_root", message: `path "${path}" resolves outside the allowed root${roots.length > 1 ? "s" : ""} (${roots.join(", ")}); access denied` },
178
+ violation: {
179
+ code: "path_not_in_root",
180
+ message: `path "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}resolves outside the allowed root${roots.length > 1 ? "s" : ""} (${roots.join(", ")}); access denied. ${PATH_NOT_IN_ROOT_ESCAPE_HINT}`,
181
+ target: key,
182
+ roots,
183
+ },
179
184
  };
180
185
  }
181
186
  return { ok: true, key };
@@ -241,6 +246,15 @@ async function canonicalizeNewPath(env, abs, signal) {
241
246
  export function violationText(toolName, v) {
242
247
  return `Error (${toolName}): ${v.message}`;
243
248
  }
249
+ export function violationDetails(v) {
250
+ return v.code === "path_not_in_root" && v.target !== undefined
251
+ ? { type: "path_not_in_root", code: "path_not_in_root", target: v.target, roots: v.roots ?? [] }
252
+ : undefined;
253
+ }
254
+ export function withinAnyRoot(rootsCanonical, p) {
255
+ return rootsCanonical.some((r) => within(r, p));
256
+ }
257
+ export const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s) and the deployment permits it, use the Bash tool — it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
244
258
  export function requireRead(state, key) {
245
259
  const entry = state.get(key);
246
260
  if (entry === undefined || entry.isPartialView) {
@@ -91,17 +91,31 @@ export function createMonitorTool(env, opts) {
91
91
  return errorResult(`Error (Monitor): the watch process was started but could not be registered (${e instanceof Error ? e.message : String(e)}); it has been terminated.`);
92
92
  }
93
93
  const bgTimeout = env.backgroundCapabilities.defaultBgTimeoutSec;
94
+ const wasClamped = timeout_ms !== undefined && timeout_ms !== timeoutMs;
95
+ const clampNote = !isPersistent && wasClamped ? ` (clamped to ${timeoutMs}ms from your requested ${timeout_ms}ms)` : "";
96
+ const timeoutIgnoredNote = isPersistent && timeout_ms !== undefined ? ` (the timeout_ms you passed is ignored because persistent is true)` : "";
94
97
  const lifetime = isPersistent
95
98
  ? typeof bgTimeout === "number"
96
- ? `It is persistent: no watch timeout of its own, but the execution environment kills background processes after ${bgTimeout}s; it runs until then, until you stop it, or until the session ends.`
97
- : "It is persistent: no watch timeout of its own, but the execution environment's background time budget still applies; it runs until then, until you stop it, or until the session ends."
98
- : `It will be killed after ${timeoutMs}ms if still running.`;
99
- return onNotify !== undefined
99
+ ? `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but the execution environment kills background processes after ${bgTimeout}s; it runs until then, until you stop it, or until the session ends.`
100
+ : `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but the execution environment's background time budget still applies; it runs until then, until you stop it, or until the session ends.`
101
+ : `It will be killed after ${timeoutMs}ms${clampNote} if still running.`;
102
+ const content = onNotify !== undefined
100
103
  ? `Monitoring in background; task_id=${taskId}. Each stdout line becomes a notification event (lines within ` +
101
104
  `~200ms are batched); you'll get a final notification with the exit code when it ends — do not poll. ` +
102
105
  `${lifetime} TaskOutput("${taskId}") reads the full spool (stdout+stderr); TaskStop("${taskId}") stops the watch.`
103
106
  : `Monitoring in background; task_id=${taskId}. No notification channel is wired in this mount — read output with ` +
104
107
  `TaskOutput("${taskId}") (re-readable spool, stdout+stderr) and stop with TaskStop("${taskId}"). ${lifetime}`;
108
+ return {
109
+ content,
110
+ details: {
111
+ type: "monitor-start",
112
+ taskId,
113
+ persistent: isPersistent,
114
+ ...(isPersistent
115
+ ? { timeoutIgnored: timeout_ms !== undefined }
116
+ : { timeoutMs, ...(timeout_ms !== undefined ? { requestedTimeoutMs: timeout_ms } : {}), wasClamped }),
117
+ },
118
+ };
105
119
  },
106
120
  });
107
121
  }