@sema-agent/core 7.17.4 → 7.17.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/core/memory-engine/delegation-settlement.d.ts +27 -9
  3. package/dist/core/memory-engine/delegation-settlement.js +155 -46
  4. package/dist/core/memory-engine/engine.js +12 -6
  5. package/dist/core/remote-env.d.ts +23 -1
  6. package/dist/core/remote-env.js +4 -1
  7. package/dist/core/runner/prepare-hands-readface.js +10 -8
  8. package/dist/core/tool-catalog-entries.js +1 -1
  9. package/dist/tools/fs/bash-readonly-classifier.d.ts +46 -5
  10. package/dist/tools/fs/bash-readonly-classifier.js +572 -80
  11. package/dist/tools/fs/fs-bash.d.ts +28 -0
  12. package/dist/tools/fs/fs-bash.js +135 -27
  13. package/dist/tools/fs/index.d.ts +13 -0
  14. package/dist/tools/fs/index.js +1 -0
  15. package/package.json +3 -2
  16. package/wire-changes/core@7.10.0.json +739 -0
  17. package/wire-changes/core@7.11.0.json +685 -0
  18. package/wire-changes/core@7.11.1.json +112 -0
  19. package/wire-changes/core@7.11.2.json +73 -0
  20. package/wire-changes/core@7.12.0.json +356 -0
  21. package/wire-changes/core@7.13.0.json +199 -0
  22. package/wire-changes/core@7.14.0.json +578 -0
  23. package/wire-changes/core@7.15.0.json +174 -0
  24. package/wire-changes/core@7.16.0.json +132 -0
  25. package/wire-changes/core@7.17.0.json +1621 -0
  26. package/wire-changes/core@7.17.1.json +139 -0
  27. package/wire-changes/core@7.17.2.json +65 -0
  28. package/wire-changes/core@7.17.3.json +44 -0
  29. package/wire-changes/core@7.17.4.json +52 -0
  30. package/wire-changes/core@7.17.5.json +67 -0
  31. package/wire-changes/core@7.5.0.json +1063 -0
  32. package/wire-changes/core@7.5.1.json +252 -0
  33. package/wire-changes/core@7.5.2.json +418 -0
  34. package/wire-changes/core@7.6.0.json +1243 -0
  35. package/wire-changes/core@7.6.1.json +1130 -0
  36. package/wire-changes/core@7.7.0.json +509 -0
  37. package/wire-changes/core@7.8.0.json +1293 -0
  38. package/wire-changes/core@7.9.0.json +691 -0
  39. package/wire-changes/core@7.9.1.json +518 -0
  40. package/wire-changes/core@7.9.2.json +46 -0
@@ -54,6 +54,10 @@ import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
54
54
  * The other non-reversible arms stay bare on purpose — their cause is legible in the command itself (a
55
55
  * write verb, a pipe, an unlisted name), and manufacturing an account for them would put text on a card
56
56
  * that adds nothing the reader cannot already see.
57
+ *
58
+ * What the boundary half of this probe is — and what it is not — is stated once on the seat that owns it
59
+ * ({@link bashReadBoundaryProbe}, `@contract shell.read_boundary.friction_floor`); composing it with the
60
+ * shape classifier here changes what happens to the residual shell risk, never that reading.
57
61
  */
58
62
  export declare function bashReversibilityProbe(allow?: readonly string[], boundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => ReversibilityVerdict;
59
63
  /**
@@ -99,6 +103,19 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
99
103
  * The reader set is the engine's classify set ({@link BASH_CLASSIFY_DEFAULT_ALLOW}) — here it is NOT an
100
104
  * auto-allow list but the set of programs whose operands the faces read; a wider set means more reads are
101
105
  * judged, never that more runs are vouched.
106
+ *
107
+ * @contract shell.read_boundary.friction_floor — what this seat answers is a question about TEXT: which
108
+ * files the words of a command may name, resolved against the declared roots, the declared home and the
109
+ * observed working directory. It is not a question about what the process will open, and no amount of
110
+ * parsing makes it one. A deployment's containment for what a shell may reach is the `ExecutionEnv` the
111
+ * command runs in (a sandbox, a container, a remote adapter); everything here is the friction floor under
112
+ * that containment — it raises the cost of an accidental or careless read, and it is not a defence against
113
+ * a command written to defeat it. The floor's last cell belongs to the full shell's own execute leg: once
114
+ * the gate has allowed a call and before anything spawns, the candidates this seat accepted are resolved
115
+ * on the filesystem, and a target that leaves the roots only after a symlink is followed is REFUSED —
116
+ * refused rather than asked, because by then the approval step is behind the call (see `createBashTool`'s
117
+ * `readBoundary` option). The leg runs where an environment can resolve a link locally; a remote adapter
118
+ * keeps the textual answer alone.
102
119
  */
103
120
  export declare function bashReadBoundaryProbe(boundary: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => ReversibilityVerdict;
104
121
  /**
@@ -171,6 +188,17 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
171
188
  * unconfined; passing this never gates anything.
172
189
  */
173
190
  additionalRoots?: readonly string[];
191
+ /**
192
+ * The deployment's shell READ BOUNDARY — the same seat the gate's shell probes read
193
+ * ({@link bashReadBoundaryProbe}), handed to the tool so its execute leg can close the one gap a
194
+ * synchronous seat structurally cannot: the seat judges the WRITTEN spelling, the kernel opens the
195
+ * symlink-resolved target. Passed as a FUNCTION wherever the working directory is observable (an
196
+ * in-task `cd` moves the base a relative operand resolves against); evaluated per call.
197
+ *
198
+ * Absent ⇒ no recheck and no I/O: a deployment with no read boundary declared has no roots for a
199
+ * read to be outside of, exactly as the seat itself reads an absent boundary.
200
+ */
201
+ readBoundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined);
174
202
  /** Override of the `timeout` parameter's DEFAULT budget, in ms — see
175
203
  * {@link import("./index.js").HandsToolkitOptions.bashDefaultTimeoutMs} for the resolution
176
204
  * contract (options over env over constant; invalid values discarded). */
@@ -6,8 +6,8 @@ import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_MISSING_ID_MESS
6
6
  import { hasBackgroundShell } from "../../core/background-shell.js";
7
7
  import { delimitUntrusted } from "../../core/untrusted-text.js";
8
8
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
9
- import { imageMagicMatches, withinAnyRoot } from "./safety.js";
10
- import { isRemoteExecutionEnv, hasDestroy, isIsolated } from "../../core/remote-env.js";
9
+ import { imageMagicMatches, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
10
+ import { boundaryIsTheSandbox, hasDestroy, isIsolated } from "../../core/remote-env.js";
11
11
  import { ghRateLimitHint } from "./gh-rate-limit.js";
12
12
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
13
13
  import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
@@ -615,6 +615,27 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
615
615
  const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
616
616
  const bgSessionScoped = taskOpts.sessionId !== undefined && taskOpts.taskRegistry !== undefined;
617
617
  const bgEnvIsolatedOwned = hasDestroy(env) && isIsolated(env);
618
+ const recheckReaders = new Set(BASH_CLASSIFY_DEFAULT_ALLOW);
619
+ let canonicalForRoots;
620
+ const canonicalBoundaryFor = (roots) => {
621
+ const key = roots.join("\u0000");
622
+ if (canonicalForRoots?.key !== key)
623
+ canonicalForRoots = { key, boundary: createCanonicalReadBoundary(env, roots) };
624
+ return canonicalForRoots.boundary;
625
+ };
626
+ const canonicalEscape = async (command, signal) => {
627
+ const resolvedBoundary = typeof taskOpts.readBoundary === "function" ? taskOpts.readBoundary() : taskOpts.readBoundary;
628
+ if (resolvedBoundary === undefined || resolvedBoundary.face === "open")
629
+ return undefined;
630
+ const faces = [
631
+ classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
632
+ classifyBoundedReadonlyPollLoopDetailed(command, recheckReaders, resolvedBoundary),
633
+ ];
634
+ const literal = [...new Set(faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? [])]))];
635
+ const asked = new Set(faces.flatMap((v) => v.outOfRootPaths ?? []));
636
+ const patterns = [...new Set(faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]))].filter((p) => !asked.has(normalizeAbsPathLexically(p)));
637
+ return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal, patterns }, resolvedBoundary.roots, FULL_SHELL_FACE_ESCAPE_PROSE, signal);
638
+ };
618
639
  return defineTool({
619
640
  name: "Bash",
620
641
  ...toolFace("bash"),
@@ -650,6 +671,9 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
650
671
  const timeoutRefusal = bashTimeoutArgRefusal(timeout, timeoutCaps);
651
672
  if (timeoutRefusal !== undefined)
652
673
  return errorResult(timeoutRefusal, { type: "bash_invalid_timeout", code: "bash_invalid_timeout" });
674
+ const escape = await canonicalEscape(command, ctx.signal);
675
+ if (escape !== undefined)
676
+ return escape;
653
677
  if (run_in_background) {
654
678
  if (!hasBackgroundShell(env)) {
655
679
  return errorResult("Error (Bash): this environment does not support background processes (run_in_background).");
@@ -659,6 +683,8 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
659
683
  if (diagnosis !== undefined)
660
684
  throw new Error(diagnosis);
661
685
  }
686
+ if (ctx.signal?.aborted === true)
687
+ return errorResult("Error (Bash): the call was cancelled before the background command started.");
662
688
  const requestedTimeoutSec = timeout !== undefined ? Math.max(1, Math.round(timeout / 1000)) : undefined;
663
689
  const r = await env.spawnBackground(command, {
664
690
  cwd: cwdRef.current,
@@ -904,7 +930,7 @@ function pathSegmentsFrom(p, from) {
904
930
  return segs;
905
931
  }
906
932
  function createCanonicalReadBoundary(env, readRoots) {
907
- const lexicalOnly = isRemoteExecutionEnv(env);
933
+ const lexicalOnly = boundaryIsTheSandbox(env);
908
934
  let rootsOnce;
909
935
  const canonicalRoots = async (signal) => {
910
936
  rootsOnce ??= (async () => {
@@ -917,18 +943,60 @@ function createCanonicalReadBoundary(env, readRoots) {
917
943
  })();
918
944
  return await rootsOnce;
919
945
  };
920
- const resolve = async (p, signal) => {
946
+ const resolveWithoutParentSteps = async (p, signal) => {
921
947
  const parts = p.split(/[\\/]/);
922
948
  const tail = [];
949
+ let allAbsent = true;
923
950
  while (parts.length > 0) {
924
951
  const canon = await env.canonicalPath(parts.join("/") || "/", signal);
925
952
  if (canon.ok) {
926
- return [canon.value.replace(/[\\/]+$/, ""), ...[...tail].reverse()].join("/");
953
+ if (tail.length === 0)
954
+ return { value: canon.value, exact: true };
955
+ return { value: [canon.value.replace(/[\\/]+$/, ""), ...[...tail].reverse()].join("/"), exact: false, missing: allAbsent };
927
956
  }
957
+ if (canon.error.code !== "not_found" && canon.error.code !== "not_directory")
958
+ allAbsent = false;
928
959
  tail.push(parts.pop());
929
960
  }
930
961
  return undefined;
931
962
  };
963
+ const parentOf = (canonical) => normalizeAbsPathLexically(`${canonical}/..`);
964
+ const resolve = async (p, signal) => {
965
+ const parts = p.split(/[\\/]/);
966
+ const first = parts.findIndex((seg) => seg === "..");
967
+ if (first < 0)
968
+ return await resolveWithoutParentSteps(p, signal);
969
+ const headEnd = parts.slice(0, first).reduce((n, seg) => n + seg.length + 1, 0);
970
+ let head = await resolveWithoutParentSteps(p.slice(0, headEnd) || "/", signal);
971
+ if (head === undefined)
972
+ return undefined;
973
+ let pending = [];
974
+ const flush = async () => {
975
+ if (pending.length === 0)
976
+ return true;
977
+ const next = await resolveWithoutParentSteps([head.value.replace(/[\\/]+$/, ""), ...pending].join("/"), signal);
978
+ pending = [];
979
+ if (next === undefined)
980
+ return false;
981
+ head = next;
982
+ return true;
983
+ };
984
+ for (let i = first; i < parts.length; i++) {
985
+ const seg = parts[i];
986
+ if (seg === "" || seg === ".")
987
+ continue;
988
+ if (seg !== "..") {
989
+ pending.push(seg);
990
+ continue;
991
+ }
992
+ if (!(await flush()))
993
+ return undefined;
994
+ if (!head.exact)
995
+ return head;
996
+ head = { value: parentOf(head.value), exact: true };
997
+ }
998
+ return (await flush()) ? head : undefined;
999
+ };
932
1000
  const literalRootHead = (p, roots) => {
933
1001
  let best;
934
1002
  for (const r of [...readRoots, ...roots]) {
@@ -971,7 +1039,13 @@ function createCanonicalReadBoundary(env, readRoots) {
971
1039
  if (++dirsUsed > MAX_GLOB_DIRS) {
972
1040
  return { unverifiable: `"${p}" expands over more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify` };
973
1041
  }
974
- const listed = await env.listDir(dir, signal);
1042
+ const canonDir = await resolve(dir, signal);
1043
+ if (canonDir !== undefined && canonDir.missing === true)
1044
+ continue;
1045
+ if (canonDir !== undefined && !canonDir.exact) {
1046
+ return { unverifiable: `"${dir}" cannot be resolved to verify what "${seg}" would expand to` };
1047
+ }
1048
+ const listed = await env.listDir(canonDir?.value ?? dir, signal);
975
1049
  if (!listed.ok) {
976
1050
  if (listed.error.code === "not_found" || listed.error.code === "not_directory")
977
1051
  continue;
@@ -984,9 +1058,8 @@ function createCanonicalReadBoundary(env, readRoots) {
984
1058
  }
985
1059
  let dirInside = false;
986
1060
  if (isLast) {
987
- const canonDir = await resolve(dir, signal);
988
- if (canonDir !== undefined && !withinAnyRoot(roots, canonDir)) {
989
- next.push(canonDir);
1061
+ if (canonDir !== undefined && !withinAnyRoot(roots, canonDir.value)) {
1062
+ next.push(canonDir.value);
990
1063
  continue;
991
1064
  }
992
1065
  dirInside = canonDir !== undefined;
@@ -1014,12 +1087,17 @@ function createCanonicalReadBoundary(env, readRoots) {
1014
1087
  return { escaping: [] };
1015
1088
  const roots = await canonicalRoots(signal);
1016
1089
  const escaping = [];
1090
+ let unresolvable;
1017
1091
  const judge = async (p) => {
1018
1092
  const real = await resolve(p, signal);
1019
1093
  if (real === undefined)
1020
1094
  return;
1021
- if (!withinAnyRoot(roots, real) && !escaping.includes(real))
1022
- escaping.push(real);
1095
+ if (!real.exact && real.missing !== true) {
1096
+ unresolvable ??= `"${p}" cannot be resolved to verify where it reads`;
1097
+ return;
1098
+ }
1099
+ if (!withinAnyRoot(roots, real.value) && !escaping.includes(real.value))
1100
+ escaping.push(real.value);
1023
1101
  };
1024
1102
  for (const p of paths.literal)
1025
1103
  await judge(p);
@@ -1030,7 +1108,13 @@ function createCanonicalReadBoundary(env, readRoots) {
1030
1108
  for (const t of expanded !== undefined && expanded.paths.length > 0 ? expanded.paths : [p])
1031
1109
  await judge(t);
1032
1110
  }
1033
- return { escaping };
1111
+ return unresolvable === undefined ? { escaping } : { escaping, unverifiable: unresolvable };
1112
+ },
1113
+ canonicalOf: async (path, signal) => {
1114
+ if (lexicalOnly)
1115
+ return undefined;
1116
+ const real = await resolve(path, signal);
1117
+ return real === undefined || (!real.exact && real.missing !== true) ? undefined : real.value;
1034
1118
  },
1035
1119
  allResolveInside: async (paths, signal) => {
1036
1120
  if (lexicalOnly || paths.length === 0)
@@ -1038,13 +1122,38 @@ function createCanonicalReadBoundary(env, readRoots) {
1038
1122
  const roots = await canonicalRoots(signal);
1039
1123
  for (const p of paths) {
1040
1124
  const real = await resolve(p, signal);
1041
- if (real === undefined || !withinAnyRoot(roots, real))
1125
+ if (real === undefined || (!real.exact && real.missing !== true))
1126
+ return false;
1127
+ if (!withinAnyRoot(roots, real.value))
1042
1128
  return false;
1043
1129
  }
1044
1130
  return true;
1045
1131
  },
1046
1132
  };
1047
1133
  }
1134
+ async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, signal) {
1135
+ const resolved = await canonical.escapesAfterResolution(candidates, signal);
1136
+ if (resolved.unverifiable !== undefined) {
1137
+ return errorResult(`Error (Bash): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1138
+ face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: [] });
1139
+ }
1140
+ if (resolved.escaping.length > 0) {
1141
+ const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
1142
+ return errorResult(`Error (Bash): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1143
+ face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: resolved.escaping });
1144
+ }
1145
+ return undefined;
1146
+ }
1147
+ const READONLY_FACE_ESCAPE_PROSE = {
1148
+ subject: "a path this command reads",
1149
+ verdictPhrase: ` ${NOT_AUTO_ALLOWED}`,
1150
+ note: BASH_READONLY_CONFINEMENT_NOTE,
1151
+ };
1152
+ const FULL_SHELL_FACE_ESCAPE_PROSE = {
1153
+ subject: "a path this command names",
1154
+ verdictPhrase: "",
1155
+ note: "The read boundary judged this command's written spelling before the call was approved, and execution is past the approval step — a target reached only through symlink resolution is refused here rather than escalated.",
1156
+ };
1048
1157
  export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1049
1158
  const timeoutCaps = resolveBashTimeoutCaps(opts);
1050
1159
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
@@ -1054,11 +1163,14 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1054
1163
  const readsOnlyEngineOverflowSpool = async (verdict) => {
1055
1164
  if (verdict.outOfRootRead !== true)
1056
1165
  return false;
1166
+ if ((verdict.unresolvedOperands ?? []).length > 0)
1167
+ return false;
1057
1168
  const paths = verdict.outOfRootPaths ?? [];
1058
1169
  if (paths.length === 0)
1059
1170
  return false;
1060
- for (const p of paths) {
1061
- if (!(await overflowSpoolFence(p)))
1171
+ for (const p of [...paths, ...(verdict.outOfRootSpellings ?? [])]) {
1172
+ const real = await canonicalBoundary.canonicalOf(p);
1173
+ if (!(await overflowSpoolFence(real ?? p)))
1062
1174
  return false;
1063
1175
  }
1064
1176
  return true;
@@ -1071,7 +1183,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1071
1183
  "operators (pipes, redirects, ;, &&, command substitution, subshells) are rejected and only " +
1072
1184
  "allowlisted commands run. Reads are confined to the workspace root(s): a path outside them is " +
1073
1185
  "refused, as is one that cannot be resolved statically (e.g. a `~` path). " +
1074
- (isRemoteExecutionEnv(env) ? "" : "A path that leads outside via a symlink counts as outside, including one a glob pattern expands to. ") +
1186
+ (boundaryIsTheSandbox(env) ? "" : "A path that leads outside via a symlink counts as outside, including one a glob pattern expands to. ") +
1075
1187
  "Still subject to the deployment's approval policy.",
1076
1188
  parameters: Type.Object({
1077
1189
  command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
@@ -1087,21 +1199,17 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1087
1199
  return errorResult(`Error (Bash): ${reason}`);
1088
1200
  const boundary = classifySimpleCommandReadBoundary(command, { roots: readRoots, cwd: rootCanonical });
1089
1201
  if (boundary.reason !== undefined) {
1090
- const rescued = boundary.outOfRootRead === true && (await canonicalBoundary.allResolveInside(boundary.outOfRootPaths ?? [], ctx.signal));
1202
+ const rescueCandidates = [...(boundary.outOfRootPaths ?? []), ...(boundary.outOfRootSpellings ?? [])];
1203
+ const rescued = boundary.outOfRootRead === true &&
1204
+ (boundary.unresolvedOperands ?? []).length === 0 &&
1205
+ (await canonicalBoundary.allResolveInside(rescueCandidates, ctx.signal));
1091
1206
  if (!rescued && !(await readsOnlyEngineOverflowSpool(boundary))) {
1092
1207
  return errorResult(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
1093
1208
  }
1094
1209
  }
1095
- const resolved = await canonicalBoundary.escapesAfterResolution({ literal: boundary.checkedPaths ?? [], patterns: boundary.undecidedPaths ?? [] }, ctx.signal);
1096
- if (resolved.unverifiable !== undefined) {
1097
- return errorResult(`Error (Bash): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1098
- BASH_READONLY_CONFINEMENT_NOTE, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: [] });
1099
- }
1100
- if (resolved.escaping.length > 0) {
1101
- const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
1102
- return errorResult(`Error (Bash): a path this command reads resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")} ${NOT_AUTO_ALLOWED}. ` +
1103
- BASH_READONLY_CONFINEMENT_NOTE, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: resolved.escaping });
1104
- }
1210
+ const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? [])], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, ctx.signal);
1211
+ if (escape !== undefined)
1212
+ return escape;
1105
1213
  return await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, true);
1106
1214
  },
1107
1215
  });
@@ -11,6 +11,7 @@ export * from "./fs-search-tools.js";
11
11
  export * from "./bash-readonly-classifier.js";
12
12
  export * from "./fs-bash.js";
13
13
  export * from "./read-deny.js";
14
+ import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
14
15
  export { BASH_CLASSIFY_DEFAULT_ALLOW } from "./bash-readonly-classifier.js";
15
16
  import { type ReadDenyEntry } from "./read-deny.js";
16
17
  export * from "./read-face.js";
@@ -40,6 +41,18 @@ export interface HandsToolkitOptions {
40
41
  readOnly?: boolean;
41
42
  /** Override the `bash_readonly` command allowlist (default {@link BASH_READONLY_DEFAULT_ALLOW}). */
42
43
  bashReadonlyAllow?: readonly string[];
44
+ /** The deployment's shell READ BOUNDARY, for the FULL `bash` face's execute-time canonical recheck
45
+ * (see `createBashTool`'s `readBoundary`). The caller passes the SAME seat it gives the gate's shell
46
+ * probes, so the boundary that judges a command's written spelling before approval and the leg that
47
+ * re-judges its symlink-resolved targets before execution are one declaration. Ignored in `readOnly`
48
+ * mode — `bash_readonly` builds its own from the roots it is confined to.
49
+ *
50
+ * ABSENT ⇒ the leg does not run and costs no I/O: with no boundary declared there is no "outside" for
51
+ * a read to be outside of, which is the same reading the probe seats take of an absent boundary. It
52
+ * deliberately does NOT fall back to `additionalRoots`/`rootCanonical`: those are the WRITE side's
53
+ * fence, they exclude `additionalReadRoots`, and standing a second root table up here would refuse
54
+ * reads this deployment sanctions while claiming to enforce the boundary it was never given. */
55
+ shellReadBoundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined);
43
56
  /** Commit-message attribution trailer for the `bash` git protocol. Default NONE — attribution is a
44
57
  * deployment identity asset, so the branded deployment sets it; pass `"Name <email>"` to advertise
45
58
  * one, `false` (≡ unset) for no trailer section. */
@@ -74,6 +74,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
74
74
  ...(opts.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: opts.autoBackgroundOnTimeout } : {}),
75
75
  ...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}),
76
76
  ...(additionalRoots !== undefined ? { additionalRoots } : {}),
77
+ ...(opts.shellReadBoundary !== undefined ? { readBoundary: opts.shellReadBoundary } : {}),
77
78
  ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
78
79
  ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
79
80
  ...(opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.17.4",
3
+ "version": "7.17.6",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -40,7 +40,8 @@
40
40
  "README.md",
41
41
  "NOTICE.md",
42
42
  "CHANGELOG.md",
43
- "LICENSE"
43
+ "LICENSE",
44
+ "wire-changes/"
44
45
  ],
45
46
  "engines": {
46
47
  "node": ">=20.3.0"