@sema-agent/core 7.23.3 → 7.23.5

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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/agents/list-agents-tool.d.ts +5 -1
  3. package/dist/agents/list-agents-tool.js +14 -2
  4. package/dist/brain/anthropic.js +3 -2
  5. package/dist/brain/circuit-breaker.js +3 -3
  6. package/dist/brain/degrading.js +3 -5
  7. package/dist/brain/errors.d.ts +35 -18
  8. package/dist/brain/errors.js +49 -31
  9. package/dist/brain/failover.js +2 -3
  10. package/dist/brain/open-responses.js +6 -5
  11. package/dist/brain/openai.js +6 -5
  12. package/dist/brain/route-adjudicator.d.ts +26 -4
  13. package/dist/brain/route-adjudicator.js +26 -0
  14. package/dist/brain/stream-engine.js +8 -2
  15. package/dist/core/checkpoint-store.d.ts +4 -6
  16. package/dist/core/checkpoint-store.js +2 -2
  17. package/dist/core/permission-rule-model.js +8 -5
  18. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -0
  19. package/dist/core/runner/prepare-delegation-surface.js +1 -0
  20. package/dist/core/runner/prepare-hands-readface.d.ts +4 -1
  21. package/dist/core/runner/prepare-hands-readface.js +7 -7
  22. package/dist/core/runner/prepare-task.js +2 -2
  23. package/dist/core/runner/run-leg.js +2 -13
  24. package/dist/core/task-spec.d.ts +2 -1
  25. package/dist/core/thrown-value.d.ts +30 -0
  26. package/dist/core/thrown-value.js +23 -1
  27. package/dist/core/tool-catalog-entries.js +1 -1
  28. package/dist/core/tool-model-gate.js +3 -15
  29. package/dist/core/tools.js +7 -7
  30. package/dist/core/untrusted-text.d.ts +6 -0
  31. package/dist/core/untrusted-text.js +1 -0
  32. package/dist/core/workflow-run-store-contract.js +13 -0
  33. package/dist/core/workflow-run-store.d.ts +15 -0
  34. package/dist/core/workflow-run-store.js +1 -0
  35. package/dist/engine/loop/agent-loop.js +10 -24
  36. package/dist/tools/fs/bash-program-position.d.ts +23 -0
  37. package/dist/tools/fs/bash-program-position.js +11 -0
  38. package/dist/tools/fs/bash-readonly-classifier.d.ts +64 -42
  39. package/dist/tools/fs/bash-readonly-classifier.js +33 -10
  40. package/dist/tools/fs/fs-bash.d.ts +41 -3
  41. package/dist/tools/fs/fs-bash.js +152 -58
  42. package/dist/tools/fs/index.d.ts +2 -1
  43. package/dist/tools/fs/index.js +1 -0
  44. package/dist/tools/fs/read-deny.d.ts +28 -0
  45. package/dist/tools/fs/read-deny.js +11 -0
  46. package/dist/tools/fs/safety.d.ts +1 -1
  47. package/dist/tools/fs/safety.js +2 -1
  48. package/dist/tools/monitor.d.ts +5 -0
  49. package/dist/tools/monitor.js +7 -0
  50. package/package.json +1 -1
  51. package/wire-changes/core@7.23.4.json +58 -0
  52. package/wire-changes/core@7.23.5.json +133 -0
@@ -7,14 +7,16 @@ import { RollingTailBuffer } from "../../core/exec-output-tail.js";
7
7
  import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_MISSING_ID_MESSAGE, TASK_STOP_MISSING_ID_MESSAGE, TASK_STOP_PARAMS, resolveTaskIdArg, ENV_DIRECT_TASK_TOOL_CAPS, composeTaskOutputDescription, composeTaskOutputParams, composeTaskStopDescription, } from "../../core/task-tool-shape.js";
8
8
  import { hasBackgroundShell } from "../../core/background-shell.js";
9
9
  import { delimitUntrusted } from "../../core/untrusted-text.js";
10
+ import { sliceHeadSafe, sliceTailSafe } from "../../core/surrogate-safe-slice.js";
10
11
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
11
12
  import { imageMagicMatches, normalizeAbsPathLexically, violationDetails, withinAnyRoot } from "./safety.js";
12
13
  import { boundaryIsTheSandbox, canonicalPathIsAuthoritative, hasDestroy, isIsolated } from "../../core/remote-env.js";
13
14
  import { ghRateLimitHint } from "./gh-rate-limit.js";
14
15
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, stripCwdSentinel, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
15
- import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
16
+ import { PROBE_CAUSE_PATH_MAX, PROBE_REASON_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
16
17
  import { bashClassifyDefaultAllow, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, refusedCommandMayRead, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
17
- import { NOT_AUTO_ALLOWED, SHELL_SCAN_DECLINES } from "./bash-program-position.js";
18
+ import { BOUNDARY_SEAT_READS_DECLINE, CLASSIFY_SEAT_READS_DECLINE, NOT_AUTO_ALLOWED, seatDeclineEvidence, } from "./bash-program-position.js";
19
+ import { readDenySeat, sensitivePathDenyList, SENSITIVE_PATH_DENY_SCOPE } from "./read-deny.js";
18
20
  import { toolFace } from "../../core/tool-catalog-entries.js";
19
21
  import { thrownText } from "../../core/thrown-value.js";
20
22
  const RECURSIVE_CAUSE_MAX_PATHS = 3;
@@ -34,13 +36,13 @@ export function bashReversibilityProbe(allow, boundary) {
34
36
  const backgrounded = isBackgroundShellCall(args);
35
37
  const detailed = classifyCompoundReadonlyDetailed(command, allowSet, resolved);
36
38
  if (backgrounded)
37
- return { reversible: false, ...boundaryGate(detailed) };
39
+ return { reversible: false, ...boundaryGate(detailed), ...probeAccountOf(command, allowSet, resolved, detailed) };
38
40
  if (detailed.reason === undefined) {
39
41
  if (boundaryUnread(detailed)) {
40
- const cause = recursiveReadCause(detailed);
41
- if (cause !== undefined)
42
- return { reversible: false, ...boundaryGate(detailed), cause };
43
- return { reversible: false, ...outOfRootGate() };
42
+ const account = probeAccountOf(command, allowSet, resolved, detailed);
43
+ if (account.cause !== undefined)
44
+ return { reversible: false, ...boundaryGate(detailed), ...account };
45
+ return { reversible: false, ...outOfRootGate(), ...account };
44
46
  }
45
47
  return { reversible: true };
46
48
  }
@@ -50,9 +52,8 @@ export function bashReversibilityProbe(allow, boundary) {
50
52
  const seatOffer = (verdict) => (offers ? readRootCandidateOf(verdict) : {});
51
53
  const loopScan = refusedCommandScan(command, allowSet, resolved, loop, CLASSIFY_SEAT_DECLINE_EVIDENCE);
52
54
  if (boundaryDeclared(loop) || unjudgedRead(command, allowSet, resolved, loop, loopScan))
53
- return { reversible: false, mandated: true, ...seatOffer(loop) };
54
- const refusedCause = recursiveReadCause(detailed);
55
- return { reversible: false, ...boundaryGate(detailed), ...seatOffer(detailed), ...(refusedCause !== undefined ? { cause: refusedCause } : {}) };
55
+ return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, loop), ...seatOffer(loop) };
56
+ return { reversible: false, ...boundaryGate(detailed), ...seatOffer(detailed), ...probeAccountOf(command, allowSet, resolved, detailed) };
56
57
  };
57
58
  return (args) => {
58
59
  const command = shellCommandOf(args);
@@ -89,8 +90,8 @@ function shellCommandOf(args) {
89
90
  function isBackgroundShellCall(args) {
90
91
  return args?.run_in_background === true;
91
92
  }
92
- export const BOUNDARY_SEAT_DECLINE_EVIDENCE = new Set(SHELL_SCAN_DECLINES);
93
- export const CLASSIFY_SEAT_DECLINE_EVIDENCE = new Set();
93
+ export const BOUNDARY_SEAT_DECLINE_EVIDENCE = seatDeclineEvidence(BOUNDARY_SEAT_READS_DECLINE);
94
+ export const CLASSIFY_SEAT_DECLINE_EVIDENCE = seatDeclineEvidence(CLASSIFY_SEAT_READS_DECLINE);
94
95
  function readBoundaryMandate(command, allowSet, resolved, verdict, declineEvidence) {
95
96
  if (resolved === undefined)
96
97
  return {};
@@ -98,7 +99,7 @@ function readBoundaryMandate(command, allowSet, resolved, verdict, declineEviden
98
99
  if (denyJudgeSpoke(verdict) || unjudgedRead(command, allowSet, resolved, verdict, scan))
99
100
  return { mandated: true };
100
101
  const named = classifyOutOfRootReadGate(command, allowSet, resolved);
101
- if (named.gated || named.denied === true)
102
+ if (named.gated || named.denied !== undefined)
102
103
  return { mandated: true };
103
104
  return declinedRung(scan);
104
105
  }
@@ -121,7 +122,7 @@ function unjudgedRead(command, allowSet, resolved, verdict, scan) {
121
122
  if (resolved === undefined)
122
123
  return false;
123
124
  const named = classifyOutOfRootReadGate(command, allowSet, resolved);
124
- return named.gated || named.denied === true;
125
+ return named.gated || named.denied !== undefined;
125
126
  }
126
127
  function recursiveReadCause(detailed) {
127
128
  const recursive = detailed.recursiveReadPaths;
@@ -135,17 +136,65 @@ function recursiveReadCause(detailed) {
135
136
  ...(others.length > 0 ? { further: operandFamily(others) } : {}),
136
137
  };
137
138
  }
139
+ function denyHitReason(finding) {
140
+ if (finding === undefined)
141
+ return {};
142
+ const { path, pattern } = finding;
143
+ const composed = (target, pat) => `reading "${target}" matches ${sensitivePathDenyList(pat)}.`;
144
+ const room = PROBE_REASON_MAX - composed("", "").length;
145
+ const patternRoom = Math.min(pattern.length, Math.max(Math.floor(room / 2), room - path.length));
146
+ return { reason: composed(fitMiddle(path, room - patternRoom), fitMiddle(pattern, patternRoom)) };
147
+ }
148
+ export function fitMiddle(s, budget) {
149
+ return s.length <= budget ? s : elideMiddle(s, budget);
150
+ }
151
+ export function elideMiddle(s, budget) {
152
+ if (budget < 12)
153
+ return sliceHeadSafe(s, Math.max(0, budget));
154
+ const keep = budget - 1;
155
+ const head = Math.ceil(keep / 2);
156
+ return `${sliceHeadSafe(s, head)}\u2026${sliceTailSafe(s, keep - head)}`;
157
+ }
158
+ function denyFindingOf(command, allowSet, resolved, verdict) {
159
+ if (verdict.readDenied === true && verdict.readDeniedPath !== undefined && verdict.readDeniedPattern !== undefined) {
160
+ return { path: verdict.readDeniedPath, pattern: verdict.readDeniedPattern };
161
+ }
162
+ return resolved === undefined ? undefined : classifyOutOfRootReadGate(command, allowSet, resolved).denied;
163
+ }
164
+ function recursiveDenyReason(resolved, verdict) {
165
+ const roots = verdict.recursiveReadPaths;
166
+ const entries = resolved?.denyEntries;
167
+ if (roots === undefined || roots.length === 0 || entries === undefined || entries.length === 0)
168
+ return {};
169
+ const composed = (root, rows) => `a recursive read form traverses "${root}", which this walk does not enumerate, past ${sensitivePathDenyList(rows)}.`;
170
+ const root = roots[0] ?? "";
171
+ const label = (rows) => rows.length < entries.length ? [...rows, `+${entries.length - rows.length} more`] : rows;
172
+ let rows = entries.slice(0, MAX_DENY_ROWS_NAMED);
173
+ while (rows.length > 1 && PROBE_REASON_MAX - composed("", label(rows)).length < MIN_TRAVERSAL_ROOT_ROOM)
174
+ rows = rows.slice(0, -1);
175
+ let shown = label(rows);
176
+ if (PROBE_REASON_MAX - composed("", shown).length < MIN_TRAVERSAL_ROOT_ROOM) {
177
+ const over = MIN_TRAVERSAL_ROOT_ROOM - (PROBE_REASON_MAX - composed("", shown).length);
178
+ shown = label([fitMiddle(rows[0] ?? "", Math.max((rows[0] ?? "").length - over, 8))]);
179
+ }
180
+ return { reason: composed(fitMiddle(root, Math.max(PROBE_REASON_MAX - composed("", shown).length, 0)), shown) };
181
+ }
182
+ const MAX_DENY_ROWS_NAMED = 3;
183
+ const MIN_TRAVERSAL_ROOT_ROOM = 24;
184
+ function probeAccountOf(command, allowSet, resolved, verdict) {
185
+ const cause = recursiveReadCause(verdict);
186
+ const hit = denyHitReason(denyFindingOf(command, allowSet, resolved, verdict));
187
+ return { ...(hit.reason !== undefined ? hit : recursiveDenyReason(resolved, verdict)), ...(cause !== undefined ? { cause } : {}) };
188
+ }
138
189
  export function bashReadBoundaryProbe(boundary) {
139
190
  const allowSet = new Set(bashClassifyDefaultAllow());
140
191
  const judge = (command, resolved, offers) => {
141
192
  const seatVerdict = (verdict) => {
142
193
  if (boundaryDeclared(verdict) || unjudgedRead(command, allowSet, resolved, verdict, refusedCommandScan(command, allowSet, resolved, verdict, BOUNDARY_SEAT_DECLINE_EVIDENCE))) {
143
- const cause = recursiveReadCause(verdict);
144
- return { reversible: false, mandated: true, ...(cause !== undefined ? { cause } : {}) };
194
+ return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, verdict) };
145
195
  }
146
196
  if (boundaryUndecided(verdict)) {
147
- const cause = recursiveReadCause(verdict);
148
- return { reversible: false, ...(cause !== undefined ? { cause } : {}) };
197
+ return { reversible: false, ...probeAccountOf(command, allowSet, resolved, verdict) };
149
198
  }
150
199
  if (verdict.reason === undefined)
151
200
  return { reversible: true };
@@ -164,7 +213,7 @@ export function bashReadBoundaryProbe(boundary) {
164
213
  if (mandated !== undefined)
165
214
  return { ...mandated, ...offerFrom(detailed), ...offerFrom(loop) };
166
215
  if (readBoundaryMandate(command, allowSet, resolved, detailed, BOUNDARY_SEAT_DECLINE_EVIDENCE).mandated === true)
167
- return { reversible: false, mandated: true };
216
+ return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, detailed) };
168
217
  return compoundVerdict ?? loopVerdict ?? { reversible: true };
169
218
  };
170
219
  return (args) => {
@@ -762,30 +811,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
762
811
  const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
763
812
  const bgSessionScoped = taskOpts.sessionId !== undefined && taskOpts.taskRegistry !== undefined;
764
813
  const bgEnvIsolatedOwned = hasDestroy(env) && isIsolated(env);
765
- const recheckReaders = new Set(bashClassifyDefaultAllow());
766
- let canonicalForRoots;
767
- const canonicalBoundaryFor = (roots) => {
768
- const key = roots.join("\u0000");
769
- if (canonicalForRoots?.key !== key)
770
- canonicalForRoots = { key, boundary: createCanonicalReadBoundary(env, roots) };
771
- return canonicalForRoots.boundary;
772
- };
773
- const canonicalEscape = async (command, signal) => {
774
- const resolvedBoundary = typeof taskOpts.readBoundary === "function" ? taskOpts.readBoundary() : taskOpts.readBoundary;
775
- if (resolvedBoundary === undefined)
776
- return undefined;
777
- const ask = { deny: resolvedBoundary.denyMatch, containment: resolvedBoundary.face !== "open" };
778
- if (ask.deny === undefined && !ask.containment)
779
- return undefined;
780
- const faces = [
781
- classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
782
- classifyBoundedReadonlyPollLoopDetailed(command, recheckReaders, resolvedBoundary),
783
- ];
784
- const literal = [...new Set(faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? [])]))];
785
- const asked = new Set(faces.flatMap((v) => v.outOfRootPaths ?? []));
786
- const patterns = [...new Set(faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]))].filter((p) => !asked.has(normalizeAbsPathLexically(p)));
787
- return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal, patterns }, resolvedBoundary.roots, FULL_SHELL_FACE_ESCAPE_PROSE, ask, signal);
788
- };
814
+ const canonicalEscape = createShellCanonicalRecheck(env, taskOpts.readBoundary);
789
815
  return defineTool({
790
816
  name: "Bash",
791
817
  ...toolFace("bash"),
@@ -1258,13 +1284,15 @@ function createCanonicalReadBoundary(env, readRoots) {
1258
1284
  if (deny === undefined || denied !== undefined)
1259
1285
  return false;
1260
1286
  const folded = normalizeAbsPathLexically(p);
1287
+ if (ask.denyAnswered.has(folded))
1288
+ return false;
1261
1289
  const pattern = deny(folded);
1262
1290
  if (pattern === null)
1263
1291
  return false;
1264
1292
  denied = { path: folded, pattern };
1265
1293
  return true;
1266
1294
  };
1267
- const judge = async (p, canonicalKnown) => {
1295
+ const judge = async (p, canonicalKnown, origin) => {
1268
1296
  if (denyHit(p))
1269
1297
  return;
1270
1298
  const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : await resolve(p, signal);
@@ -1279,6 +1307,8 @@ function createCanonicalReadBoundary(env, readRoots) {
1279
1307
  }
1280
1308
  if (denyHit(real.value))
1281
1309
  return;
1310
+ if (ask.containmentAnswered.has(normalizeAbsPathLexically(origin ?? p)))
1311
+ return;
1282
1312
  if (ask.containment && !withinAnyRoot(roots, real.value) && !escaping.includes(real.value))
1283
1313
  escaping.push(real.value);
1284
1314
  };
@@ -1299,7 +1329,7 @@ function createCanonicalReadBoundary(env, readRoots) {
1299
1329
  for (const t of expanded.paths) {
1300
1330
  if (denied !== undefined)
1301
1331
  break;
1302
- await judge(t, expanded.canonical.get(t));
1332
+ await judge(t, expanded.canonical.get(t), p);
1303
1333
  }
1304
1334
  }
1305
1335
  else
@@ -1330,42 +1360,90 @@ function createCanonicalReadBoundary(env, readRoots) {
1330
1360
  },
1331
1361
  };
1332
1362
  }
1363
+ const NOTHING_ANSWERED = new Set();
1364
+ export function createShellCanonicalRecheck(env, readBoundary, face) {
1365
+ const recheckReaders = new Set(bashClassifyDefaultAllow());
1366
+ let canonicalForRoots;
1367
+ const canonicalBoundaryFor = (roots) => {
1368
+ const key = roots.join("\u0000");
1369
+ if (canonicalForRoots?.key !== key)
1370
+ canonicalForRoots = { key, boundary: createCanonicalReadBoundary(env, roots) };
1371
+ return canonicalForRoots.boundary;
1372
+ };
1373
+ return async (command, signal) => {
1374
+ const resolvedBoundary = typeof readBoundary === "function" ? readBoundary() : readBoundary;
1375
+ if (resolvedBoundary === undefined)
1376
+ return undefined;
1377
+ const containmentAsked = resolvedBoundary.face !== "open";
1378
+ if (resolvedBoundary.denyMatch === undefined && !containmentAsked)
1379
+ return undefined;
1380
+ const faces = [
1381
+ classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
1382
+ classifyBoundedReadonlyPollLoopDetailed(command, recheckReaders, resolvedBoundary),
1383
+ ];
1384
+ const named = classifyOutOfRootReadGate(command, recheckReaders, resolvedBoundary);
1385
+ const literalAll = [
1386
+ ...new Set([
1387
+ ...faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? []), ...(v.outOfRootPaths ?? [])]),
1388
+ ...named.checked,
1389
+ ...named.paths,
1390
+ ]),
1391
+ ];
1392
+ const folded = (ps) => new Set(ps.map((p) => normalizeAbsPathLexically(p)));
1393
+ const answered = {
1394
+ containment: folded([...faces.flatMap((v) => v.outOfRootPaths ?? []), ...named.paths]),
1395
+ deny: folded([...faces.flatMap((v) => (v.readDeniedPath !== undefined ? [v.readDeniedPath] : [])), ...(named.denied !== undefined ? [named.denied.path] : [])]),
1396
+ };
1397
+ const patterns = [...new Set([...faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]), ...named.undecided])];
1398
+ return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal: literalAll, patterns }, resolvedBoundary.roots, face ?? FULL_SHELL_FACE_ESCAPE_PROSE, { deny: resolvedBoundary.denyMatch, containment: containmentAsked, denyAnswered: answered.deny, containmentAnswered: answered.containment }, signal);
1399
+ };
1400
+ }
1333
1401
  async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, ask, signal) {
1334
1402
  const resolved = await canonical.escapesAfterResolution(candidates, ask, signal);
1335
1403
  if (resolved.denied !== undefined) {
1336
- const content = `Error (Bash): reading "${resolved.denied.path}" is refused: the target matches the sensitive-path ` +
1337
- `read deny list (pattern "${resolved.denied.pattern}"). This list guards credential-class paths and ` +
1338
- `applies regardless of the containment roots. ${DENY_REFUSAL_NOTE}`;
1339
- return errorResult(content, violationDetails({ code: "read_path_denied", message: content, target: resolved.denied.path, pattern: resolved.denied.pattern }));
1404
+ return readDenyRefusal(resolved.denied.path, resolved.denied.pattern, face.tool);
1340
1405
  }
1341
1406
  if (resolved.unverifiable !== undefined) {
1342
- return errorResult(`Error (Bash): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1343
- face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: [] });
1407
+ return readContainmentRefusal(`Error (${face.tool}): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1408
+ face.note);
1344
1409
  }
1345
1410
  if (resolved.escaping.length > 0) {
1346
1411
  const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
1347
- return errorResult(`Error (Bash): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1348
- face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: resolved.escaping });
1412
+ return readContainmentRefusal(`Error (${face.tool}): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1413
+ face.note, resolved.escaping);
1349
1414
  }
1350
1415
  return undefined;
1351
1416
  }
1352
- const DENY_REFUSAL_NOTE = "The read boundary judged this command's written spelling before the call was approved; what the shell " +
1353
- "would actually read was established only at execution, and a deny-listed read is refused here rather than escalated.";
1417
+ const DENY_REFUSAL_NOTE = "The read boundary judges this command's written spelling and, where a filesystem can be consulted, the " +
1418
+ "paths it would actually read; a deny-listed read is refused here rather than escalated.";
1419
+ function readContainmentRefusal(content, paths = []) {
1420
+ return errorResult(content, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths });
1421
+ }
1422
+ function readDenyRefusal(target, pattern, tool = "Bash") {
1423
+ const content = `Error (${tool}): reading "${target}" is refused: the target matches ${sensitivePathDenyList(pattern)}. ` +
1424
+ `${SENSITIVE_PATH_DENY_SCOPE} ${DENY_REFUSAL_NOTE}`;
1425
+ return errorResult(content, violationDetails({ code: "read_path_denied", message: content, target, pattern }));
1426
+ }
1354
1427
  const READONLY_FACE_ESCAPE_PROSE = {
1428
+ tool: "Bash",
1355
1429
  subject: "a path this command reads",
1356
1430
  verdictPhrase: ` ${NOT_AUTO_ALLOWED}`,
1357
1431
  note: BASH_READONLY_CONFINEMENT_NOTE,
1358
1432
  };
1359
1433
  const FULL_SHELL_FACE_ESCAPE_PROSE = {
1434
+ tool: "Bash",
1360
1435
  subject: "a path this command names",
1361
1436
  verdictPhrase: "",
1362
1437
  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.",
1363
1438
  };
1439
+ export const MONITOR_ESCAPE_PROSE = { ...FULL_SHELL_FACE_ESCAPE_PROSE, tool: "Monitor" };
1364
1440
  export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1365
1441
  const timeoutCaps = resolveBashTimeoutCaps(opts);
1366
1442
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
1367
1443
  const sample = [...allow].slice(0, 6).join(", ");
1368
1444
  const readRoots = [rootCanonical, ...(opts?.additionalRoots ?? [])];
1445
+ const denySeat = readDenySeat(opts?.readDeny);
1446
+ const readBoundary = { roots: readRoots, cwd: rootCanonical, ...denySeat };
1369
1447
  const overflowSpoolFence = createShellOverflowSpoolFence(env);
1370
1448
  const readsOnlyEngineOverflowSpool = async (verdict) => {
1371
1449
  if (verdict.outOfRootRead !== true)
@@ -1377,6 +1455,9 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1377
1455
  return false;
1378
1456
  for (const p of [...paths, ...(verdict.outOfRootSpellings ?? [])]) {
1379
1457
  const real = await canonicalBoundary.canonicalOf(p);
1458
+ for (const view of [real, p])
1459
+ if (view !== undefined && denySeat.denyMatch?.(normalizeAbsPathLexically(view)) != null)
1460
+ return false;
1380
1461
  if (!(await overflowSpoolFence(real ?? p)))
1381
1462
  return false;
1382
1463
  }
@@ -1404,17 +1485,30 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1404
1485
  const reason = coarseReadonlyCheck(command, allow);
1405
1486
  if (reason)
1406
1487
  return errorResult(`Error (Bash): ${reason}`);
1407
- const boundary = classifySimpleCommandReadBoundary(command, { roots: readRoots, cwd: rootCanonical });
1488
+ const boundary = classifySimpleCommandReadBoundary(command, readBoundary);
1489
+ let releasedByGrant = [];
1490
+ if (boundary.readDeniedPath !== undefined && boundary.readDeniedPattern !== undefined)
1491
+ return readDenyRefusal(boundary.readDeniedPath, boundary.readDeniedPattern);
1492
+ const traversal = boundary.recursiveReadPaths ?? [];
1493
+ if (traversal.length > 0) {
1494
+ const shown = traversal.slice(0, RECURSIVE_CAUSE_MAX_PATHS);
1495
+ return readContainmentRefusal(`Error (Bash): this lexical check cannot bound what a recursive read form in this command traverses, and a deny list is in force — ` +
1496
+ `${traversal.length} path(s) taken as traversal roots: ${shown.join(", ")}${traversal.length > shown.length ? `, and ${traversal.length - shown.length} more` : ""}. ` +
1497
+ `Use the Grep tool to search a directory tree — it applies the same deny list to every file it reads. ` +
1498
+ `${BASH_READONLY_CONFINEMENT_NOTE}`);
1499
+ }
1408
1500
  if (boundary.reason !== undefined) {
1409
1501
  const rescueCandidates = [...(boundary.outOfRootPaths ?? []), ...(boundary.outOfRootSpellings ?? [])];
1410
1502
  const rescued = boundary.outOfRootRead === true &&
1411
1503
  (boundary.unresolvedOperands ?? []).length === 0 &&
1412
1504
  (await canonicalBoundary.allResolveInside(rescueCandidates, ctx.signal));
1505
+ if (rescued)
1506
+ releasedByGrant = rescueCandidates;
1413
1507
  if (!rescued && !(await readsOnlyEngineOverflowSpool(boundary))) {
1414
- return errorResult(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
1508
+ return readContainmentRefusal(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, boundary.outOfRootPaths ?? []);
1415
1509
  }
1416
1510
  }
1417
- const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? [])], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, { deny: undefined, containment: true }, ctx.signal);
1511
+ const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? []), ...releasedByGrant], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, { deny: denySeat.denyMatch, containment: true, denyAnswered: NOTHING_ANSWERED, containmentAnswered: NOTHING_ANSWERED }, ctx.signal);
1418
1512
  if (escape !== undefined)
1419
1513
  return escape;
1420
1514
  const readOnlyTick = opts?.onToolProgress === undefined ? undefined : (t) => describeSafely(opts.onToolProgress, { toolCallId: ctx.toolCallId, toolName: "Bash", ...t });
@@ -46,7 +46,8 @@ export interface HandsToolkitOptions {
46
46
  * (see `createBashTool`'s `readBoundary`). The caller passes the SAME seat it gives the gate's shell
47
47
  * probes, so the boundary that judges a command's written spelling before approval and the leg that
48
48
  * re-judges its symlink-resolved targets before execution are one declaration. Ignored in `readOnly`
49
- * mode — `bash_readonly` builds its own from the roots it is confined to.
49
+ * mode — `bash_readonly` builds its own: containment from the roots it is confined to, DENY from the same
50
+ * compiled table this band hands the structured read faces.
50
51
  *
51
52
  * ABSENT ⇒ the leg does not run and costs no I/O: with no boundary declared there is no "outside" for
52
53
  * a read to be outside of, which is the same reading the probe seats take of an absent boundary. It
@@ -72,6 +72,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
72
72
  ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
73
73
  ...(opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {}),
74
74
  ...(readFaceRoots !== undefined ? { additionalRoots: readFaceRoots } : {}),
75
+ readDeny,
75
76
  ...(opts.onToolProgress !== undefined ? { onToolProgress: opts.onToolProgress } : {}),
76
77
  })
77
78
  : createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
@@ -61,6 +61,34 @@ export interface ReadDenyMatcher {
61
61
  * matching the deny list exist under the scope and were excluded). */
62
62
  readonly rgProbeGlobs: readonly ReadDenyRgGlob[];
63
63
  }
64
+ /** The DENY HALF of a shell read boundary, in the shape `BashReadonlyRootBoundary` declares it (structural:
65
+ * this module is the leaf every face depends on). `denyMatch` judges one LEXICALLY RESOLVED operand,
66
+ * returning the matched pattern or null; PRESENT-IFF there is something to judge. */
67
+ export interface ReadDenySeat {
68
+ denyMatch?: (resolvedPath: string) => string | null;
69
+ denyEntries: readonly string[];
70
+ }
71
+ /** The ONE adapter from a compiled matcher to that seat, so every face mounts it the SAME way and the "wire
72
+ * the judge only when it has something to judge" rule is spelled once. That rule is load-bearing: the
73
+ * seat's PRESENCE is itself read as a fact (the recursive-read demotion mints on "a deny judge is wired" =
74
+ * the check a traversal would outflank is in place), so a judge over an EMPTY set must not be wired. Call
75
+ * ONCE per mount and reuse the result — the returned closure is then the function OBJECT every face mounted
76
+ * from that matcher shares, which makes "the same judge" checkable rather than argued. */
77
+ export declare function readDenySeat(matcher: ReadDenyMatcher | undefined): ReadDenySeat;
78
+ /** The ONE naming of the deny table and the pattern that matched. Every face composes its own SUBJECT and
79
+ * VERB in front of it and its own DISPOSITION behind it (the verb is not shared: one face names the
80
+ * target, another the REQUESTED SPELLING under a symlink alias). What must not fork is the naming — a
81
+ * reader comparing two surfaces about one finding must not be reading two findings. A LIST of patterns is
82
+ * the same naming in the plural, for the one finding that names no single row: a traversal that goes PAST
83
+ * the judge names the table IN FORCE, never a match it did not make. Four composers: the
84
+ * structured read faces' refusal (`safety.ts`), the shell faces' refusal card (`readDenyRefusal`), the
85
+ * lexical walk's verdict sentence (`bash-readonly-classifier.ts`), and the full shell's MANDATED ask card,
86
+ * which is approvable and so states the finding without claiming a refusal. NOT composers, each stating
87
+ * its own fact: the compaction lane's withheld-attachment HTML attribute, and the traversal notes'
88
+ * withheld-COUNT lines, which name the table and a pattern LIST. */
89
+ export declare function sensitivePathDenyList(pattern: string | readonly string[]): string;
90
+ /** The deny table's REACH: a deny row holds INSIDE the containment roots, so no root grant answers it. */
91
+ export declare const SENSITIVE_PATH_DENY_SCOPE = "This list guards credential-class paths and applies regardless of the containment roots.";
64
92
  /**
65
93
  * The closed set of built-in tiers. A tier is the deployment's selection unit
66
94
  * ({@link ReadDenyBuiltinConfig.tiers}); every {@link READ_FACE_BUILTIN_DENY_TABLE} row belongs to
@@ -1,3 +1,14 @@
1
+ export function readDenySeat(matcher) {
2
+ const entries = (matcher?.entries ?? []).map((e) => e.pattern);
3
+ if (matcher === undefined || entries.length === 0)
4
+ return { denyEntries: entries };
5
+ return { denyMatch: (p) => matcher.matchPath(p)?.pattern ?? null, denyEntries: entries };
6
+ }
7
+ export function sensitivePathDenyList(pattern) {
8
+ const rows = typeof pattern === "string" ? [pattern] : pattern;
9
+ return `the sensitive-path read deny list (pattern${rows.length === 1 ? "" : "s"} ${rows.map((p) => `"${p}"`).join(", ")})`;
10
+ }
11
+ export const SENSITIVE_PATH_DENY_SCOPE = "This list guards credential-class paths and applies regardless of the containment roots.";
1
12
  export const READ_DENY_BUILTIN_TIERS = ["credentials", "shell-history", "browser", "wallet", "agent-config"];
2
13
  export const READ_FACE_BUILTIN_DENY_TABLE = [
3
14
  { pattern: ".ssh", tier: "credentials" },
@@ -1,3 +1,4 @@
1
+ import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
1
2
  import type { ExecutionEnv } from "../../internal/harness-types.js";
2
3
  import { protectivePathTargetOf, type ToolCallLike } from "../../core/tool-registry.js";
3
4
  /**
@@ -320,7 +321,6 @@ export declare const WIN32_NAMESPACE_REFUSAL_TEXT: {
320
321
  };
321
322
  /** The fence over the text table: `never` while every refusal word has a row. */
322
323
  export type NamespaceRefusalTextCoversEveryWord = AssertAllKeysHandled<Exclude<Win32NamespaceRefusal, keyof typeof WIN32_NAMESPACE_REFUSAL_TEXT>>;
323
- type AssertAllKeysHandled<T extends never> = T;
324
324
  /**
325
325
  * The Win32 namespace screen — run on every model-spelled target BEFORE the family join, at every site
326
326
  * (the fs throat, the effective-target reader, the rule side, the glob splitter), so one spelling is
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { sensitivePathDenyList, SENSITIVE_PATH_DENY_SCOPE } from "./read-deny.js";
2
3
  import { sliceHeadSafe } from "../../core/surrogate-safe-slice.js";
3
4
  import { pathTargetValue, protectivePathTargetOf } from "../../core/tool-registry.js";
4
5
  import { withGateTimeout, DEFAULT_GATE_AWAIT_TIMEOUT_MS } from "../../core/gate-bounded-await.js";
@@ -468,7 +469,7 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
468
469
  ok: false,
469
470
  violation: {
470
471
  code: "read_path_denied",
471
- message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged} the sensitive-path read deny list (pattern "${hit.pattern}"). This list guards credential-class paths and applies regardless of the containment roots.`,
472
+ message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged} ${sensitivePathDenyList(hit.pattern)}. ${SENSITIVE_PATH_DENY_SCOPE}`,
472
473
  target: key,
473
474
  pattern: hit.pattern,
474
475
  },
@@ -42,6 +42,7 @@ import type { AgentTool, ExecutionEnv } from "../internal/harness-types.js";
42
42
  import { type MonitorTimers, type TaskRegistry } from "../core/task-registry.js";
43
43
  import type { TaskNotificationPayload } from "../core/task-notification.js";
44
44
  import type { ToolResultStore } from "../core/tool-result-store.js";
45
+ import type { BashReadonlyRootBoundary } from "./fs/bash-readonly-classifier.js";
45
46
  export interface MonitorToolOptions {
46
47
  registry: TaskRegistry;
47
48
  /** Fallbacks when the runtime ctx carries no identity (direct mounts) — same posture as createBashTool. */
@@ -68,6 +69,10 @@ export interface MonitorToolOptions {
68
69
  * task-registry-monitor.ts's spill helper). Absent ⇒ legal degrade, byte-identical rolling (no spill,
69
70
  * same loss shape as before this design). */
70
71
  toolResultStore?: ToolResultStore;
72
+ /** The deployment's shell READ BOUNDARY — the SAME option `createBashTool` takes: this face spawns a
73
+ * shell command too, so the seat that judges the WRITTEN spelling needs the leg that re-judges where the
74
+ * kernel takes it. BUILT here, so a library mount is not fail-open on one face and closed on the other. */
75
+ readBoundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined);
71
76
  /** RB-465-b (HRD-PTL-3 残件): spec-level retain (`TaskSpec.retainBackgroundProcesses`) — the SECOND
72
77
  * retain lane, OR-composed with the env capability at the teardown sweep. Without this bit the mount
73
78
  * could only see the env lane, so a deployment retaining per spec still got the "session ends it"
@@ -3,6 +3,7 @@ import { defineTool, errorResult } from "../core/tools.js";
3
3
  import { hasBackgroundShell } from "../core/background-shell.js";
4
4
  import { MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, } from "../core/task-registry.js";
5
5
  import { toolFace } from "../core/tool-catalog-entries.js";
6
+ import { createShellCanonicalRecheck, MONITOR_ESCAPE_PROSE } from "./fs/fs-bash.js";
6
7
  import { thrownText } from "../core/thrown-value.js";
7
8
  const MONITOR_DESCRIPTION = "Run a shell command in the background and watch its stdout as a stream of events.\n" +
8
9
  "\n" +
@@ -42,6 +43,7 @@ function envBackgroundWallSec(caps, requestedSec) {
42
43
  return Math.min(base, cap);
43
44
  }
44
45
  export function createMonitorTool(env, opts) {
46
+ const canonicalRecheck = opts.readBoundary === undefined ? undefined : createShellCanonicalRecheck(env, opts.readBoundary, MONITOR_ESCAPE_PROSE);
45
47
  return defineTool({
46
48
  name: "Monitor",
47
49
  ...toolFace("monitor"),
@@ -68,6 +70,11 @@ export function createMonitorTool(env, opts) {
68
70
  const isPersistent = persistent === true;
69
71
  const timeoutMs = Math.min(MONITOR_MAX_TIMEOUT_MS, Math.max(1, Math.floor(timeout_ms ?? MONITOR_DEFAULT_TIMEOUT_MS)));
70
72
  const spawnTimeoutSec = isPersistent ? undefined : Math.ceil(timeoutMs / 1000) + 5;
73
+ const escape = await canonicalRecheck?.(command, ctx.signal);
74
+ if (escape !== undefined)
75
+ return escape;
76
+ if (ctx.signal?.aborted === true)
77
+ return errorResult("Error (Monitor): the call was cancelled before the watch started.");
71
78
  const spawn = await env.spawnBackground(command, {
72
79
  ...(opts.cwdRef !== undefined ? { cwd: opts.cwdRef.current } : {}),
73
80
  ...(spawnTimeoutSec !== undefined ? { timeout: spawnTimeoutSec } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.23.3",
3
+ "version": "7.23.5",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,58 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.23.4",
4
+ "from": "141b1687631b53cf2998a56c7541bb4aab5ca9af",
5
+ "to": "v7.23.4",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [
10
+ {
11
+ "id": "F1",
12
+ "source": "behaviorFlips.summary",
13
+ "text": "NARROWING, the `bash_readonly` read-only shell face (`handsReadOnly`), deployments with a deny judge wired (#937 = sema-internal #67, P1; stock since design/199 — measured identical on 7.22.0 and 7.23.3): ONE deny judge for every read face. The v1 exception that kept `denyMatch` off the read-only shell face is retired: `readDenySeat()` (`src/tools/fs/read-deny.ts`) is the single adapter from the compiled deny set to a boundary's deny half, mounted by the structured read faces and now by `bash_readonly` at both of its judging moments — the lexical verdict and the execute-time recheck (`src/tools/fs/fs-bash.ts`, `{ deny: denySeat.denyMatch, containment: true }`). One mint for the deny refusal (`readDenyRefusal`) and one for the containment refusal (`readContainmentRefusal`). A containment GRANT (root rescue, overflow-spool recovery) answers containment only, never deny (codex r1/r2). Rules −4. WHO NEEDS IT: a deployment that mounts the read-only shell (server autonomy read-only tier) AND configures `readDenyPatterns` / `READ_DENY_BUILTIN_TIERS` — until now `cat .env` ran there at zero cards with the bytes returned. WHO IS AFFECTED: (a) on that face, a deny-listed read is REFUSED (`read_path_denied`, naming the pattern) — named spelling, glob, symlink target, and a recursive walk that would enter a deny row; (b) a recursive verb (`grep -r <needle> .`) on such a deployment is refused as a whole with the Grep tool named as the way out (KL-937b); (c) a bracket-named glob spelling may over-fire fail-closed (KL-937a). Deployments with no deny judge: byte-identical on this face; the full Bash face: unchanged (contrast column pinned)."
14
+ }
15
+ ],
16
+ "closedSetMembers": [],
17
+ "closedSetsUnreadable": [],
18
+ "typeKeys": [
19
+ {
20
+ "type": "CompoundReadonlyVerdict",
21
+ "at": [
22
+ "src/tools/fs/bash-readonly-classifier.ts:644"
23
+ ],
24
+ "added": [
25
+ "readDeniedPath",
26
+ "readDeniedPattern"
27
+ ],
28
+ "removed": [],
29
+ "breaking": false,
30
+ "criteriaCovered": false
31
+ }
32
+ ],
33
+ "declarationMoves": [],
34
+ "mintMoves": [],
35
+ "criteriaObserves": [
36
+ {
37
+ "kind": "taskEventField",
38
+ "name": "tool_end.isError",
39
+ "assertions": [
40
+ "G1.a",
41
+ "G1.b"
42
+ ]
43
+ }
44
+ ],
45
+ "consumersHint": {
46
+ "cli": [
47
+ "CompoundReadonlyVerdict"
48
+ ],
49
+ "server": [
50
+ "CompoundReadonlyVerdict"
51
+ ],
52
+ "sdk": [],
53
+ "note": "scanned cli@4e3b6980 server@866e273b sdk@0a6aeb7 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
54
+ },
55
+ "breakingCount": 0,
56
+ "breakingItems": [],
57
+ "toResolvedAt": "2a5d3b7f6c6d633510d8866e95d66e7b2b85446a"
58
+ }