@sema-agent/core 7.17.3 → 7.17.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 (35) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/core/runner/prepare-hands-readface.js +10 -8
  3. package/dist/core/tool-catalog-entries.js +1 -1
  4. package/dist/tools/fs/bash-readonly-classifier.d.ts +18 -5
  5. package/dist/tools/fs/bash-readonly-classifier.js +194 -73
  6. package/dist/tools/fs/fs-bash.d.ts +28 -0
  7. package/dist/tools/fs/fs-bash.js +52 -10
  8. package/dist/tools/fs/index.d.ts +13 -0
  9. package/dist/tools/fs/index.js +1 -0
  10. package/package.json +3 -2
  11. package/wire-changes/core@7.10.0.json +739 -0
  12. package/wire-changes/core@7.11.0.json +685 -0
  13. package/wire-changes/core@7.11.1.json +112 -0
  14. package/wire-changes/core@7.11.2.json +73 -0
  15. package/wire-changes/core@7.12.0.json +356 -0
  16. package/wire-changes/core@7.13.0.json +199 -0
  17. package/wire-changes/core@7.14.0.json +578 -0
  18. package/wire-changes/core@7.15.0.json +174 -0
  19. package/wire-changes/core@7.16.0.json +132 -0
  20. package/wire-changes/core@7.17.0.json +1621 -0
  21. package/wire-changes/core@7.17.1.json +139 -0
  22. package/wire-changes/core@7.17.2.json +65 -0
  23. package/wire-changes/core@7.17.3.json +44 -0
  24. package/wire-changes/core@7.17.4.json +52 -0
  25. package/wire-changes/core@7.17.5.json +67 -0
  26. package/wire-changes/core@7.5.0.json +1063 -0
  27. package/wire-changes/core@7.5.1.json +252 -0
  28. package/wire-changes/core@7.5.2.json +418 -0
  29. package/wire-changes/core@7.6.0.json +1243 -0
  30. package/wire-changes/core@7.6.1.json +1130 -0
  31. package/wire-changes/core@7.7.0.json +509 -0
  32. package/wire-changes/core@7.8.0.json +1293 -0
  33. package/wire-changes/core@7.9.0.json +691 -0
  34. package/wire-changes/core@7.9.1.json +518 -0
  35. package/wire-changes/core@7.9.2.json +46 -0
@@ -109,7 +109,7 @@ export function splitShellCompoundSegments(source, options) {
109
109
  }
110
110
  const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
111
111
  const segments = [];
112
- const pipeFed = [false];
112
+ const connectors = ["start"];
113
113
  const mask = quoteMask(scanned);
114
114
  const quoted = (i) => mask.balanced && mask.quoted[i] === true;
115
115
  let cur = "";
@@ -122,7 +122,7 @@ export function splitShellCompoundSegments(source, options) {
122
122
  if (scanned[i + 1] === "&") {
123
123
  segments.push(cur);
124
124
  cur = "";
125
- pipeFed.push(false);
125
+ connectors.push("&&");
126
126
  i++;
127
127
  }
128
128
  else
@@ -132,25 +132,25 @@ export function splitShellCompoundSegments(source, options) {
132
132
  if (scanned[i + 1] === "|") {
133
133
  segments.push(cur);
134
134
  cur = "";
135
- pipeFed.push(false);
135
+ connectors.push("||");
136
136
  i++;
137
137
  }
138
138
  else {
139
139
  segments.push(cur);
140
140
  cur = "";
141
- pipeFed.push(true);
141
+ connectors.push("|");
142
142
  }
143
143
  }
144
144
  else if (c === ";") {
145
145
  segments.push(cur);
146
146
  cur = "";
147
- pipeFed.push(false);
147
+ connectors.push(";");
148
148
  }
149
149
  else
150
150
  cur += c;
151
151
  }
152
152
  segments.push(cur);
153
- return { segments, pipeFed };
153
+ return { segments, pipeFed: connectors.map((k) => k === "|"), connectors };
154
154
  }
155
155
  const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
156
156
  function stripExemptRedirections(command) {
@@ -259,7 +259,8 @@ function tokensMoveBase(toks, allow) {
259
259
  const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
260
260
  return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
261
261
  }
262
- const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
262
+ const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.|parallel)$/;
263
+ const ARGV_REENTRY_PROGRAM_RE = /^xargs$/;
263
264
  export function refusedCommandMayRead(command, allow, boundary) {
264
265
  return !refusedCommandExempt(command, allow, boundary, 0);
265
266
  }
@@ -579,6 +580,8 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
579
580
  const unwrapped = unwrapLauncher(toks.slice(i).map(fold), allow);
580
581
  if (unwrapped.kind === "refused")
581
582
  return false;
583
+ if (argvReentryUnjudged(unwrapped))
584
+ return false;
582
585
  if (unwrapped.kind === "unlisted") {
583
586
  if (!unlistedSegmentJudgedInside(toks, i))
584
587
  return false;
@@ -621,18 +624,35 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
621
624
  return true;
622
625
  }
623
626
  function unwrapLauncher(folded, allow) {
627
+ let argvFromStdin = ARGV_REENTRY_PROGRAM_RE.test(programBasename(folded[0] ?? ""));
628
+ let lastReentry = argvFromStdin ? 0 : -1;
624
629
  for (let i = 1; i < folded.length; i++) {
625
630
  const t = folded[i];
626
- if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
631
+ if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t))
632
+ continue;
633
+ const wordBasename = programBasename(t);
634
+ if (ARGV_REENTRY_PROGRAM_RE.test(wordBasename)) {
635
+ argvFromStdin = true;
636
+ lastReentry = i;
637
+ continue;
638
+ }
639
+ if (COMMAND_LAUNCHERS.has(wordBasename))
627
640
  continue;
628
641
  if (t.startsWith("-"))
629
642
  return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
630
- const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
643
+ const head = wordBasename;
631
644
  if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
632
645
  return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
633
- return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted", head };
646
+ return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head, argvFromStdin } : { kind: "unlisted", head, argvFromStdin };
634
647
  }
635
- return { kind: "unlisted" };
648
+ return { kind: "unlisted", argvFromStdin, runsDefaultProgram: lastReentry === folded.length - 1 };
649
+ }
650
+ const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
651
+ function argvReentryUnjudged(unwrapped) {
652
+ if (!unwrapped.argvFromStdin)
653
+ return false;
654
+ const runs = unwrapped.head ?? (unwrapped.runsDefaultProgram === true ? ARGV_REENTRY_DEFAULT_PROGRAM : undefined);
655
+ return runs === undefined || !NO_PATH_OPERAND_COMMANDS.has(runs);
636
656
  }
637
657
  function tokenizeSegment(segment) {
638
658
  const raw = splitWordsQuoteAware(segment);
@@ -983,13 +1003,14 @@ function isGrepFileStdinLongOption(tok) {
983
1003
  const name = longOptionNameOf(tok);
984
1004
  return name !== undefined && isLongOptionAbbrevOf(name, "file");
985
1005
  }
1006
+ const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
986
1007
  function grepClusterValueOwner(tok) {
987
- if (!/^-[A-Za-z]/.test(tok) || tok.startsWith("--"))
1008
+ if (!tok.startsWith("-") || tok.startsWith("--") || !CLUSTER_OPTION_CHAR.test(tok[1] ?? ""))
988
1009
  return undefined;
989
1010
  for (const ch of tok.slice(1)) {
990
1011
  if (ch === "e" || ch === "f")
991
1012
  return ch;
992
- if (!/[A-Za-z]/.test(ch))
1013
+ if (!CLUSTER_OPTION_CHAR.test(ch))
993
1014
  return undefined;
994
1015
  }
995
1016
  return undefined;
@@ -1003,8 +1024,11 @@ function attachedOptionPayloads(tok) {
1003
1024
  out.push(literal(tok.slice(eq + 1)));
1004
1025
  return out;
1005
1026
  }
1006
- for (let k = 1; k <= tok.length; k++) {
1007
- if (!/^[A-Za-z]*$/.test(tok.slice(1, k)))
1027
+ const whole = literal(tok.slice(1));
1028
+ if (whole.length > 0 && (isPathShapedToken(whole) || isAbsolutePathForm(whole)))
1029
+ out.push(whole);
1030
+ for (let k = 2; k <= tok.length; k++) {
1031
+ if (![...tok.slice(1, k)].every((ch) => CLUSTER_OPTION_CHAR.test(ch)))
1008
1032
  break;
1009
1033
  const payload = tok.slice(k);
1010
1034
  if (payload.length > 0)
@@ -1020,6 +1044,9 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1020
1044
  const findings = [];
1021
1045
  const candidates = [];
1022
1046
  const bareWordOperands = [];
1047
+ const judgeOperand = (text, globbed) => {
1048
+ (isPathShapedToken(text) || isAbsolutePathForm(text) ? candidates : bareWordOperands).push({ text, globbed });
1049
+ };
1023
1050
  const positionalOperands = [];
1024
1051
  let stdinOperand = false;
1025
1052
  const argGlobs = (k) => {
@@ -1076,10 +1103,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1076
1103
  if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
1077
1104
  continue;
1078
1105
  if (t.startsWith("-") && t !== "-") {
1079
- for (const payload of attachedOptionPayloads(t)) {
1080
- if (isAbsolutePathForm(payload) || isPathShapedToken(payload))
1081
- candidates.push({ text: payload, globbed: argGlobs(k) });
1082
- }
1106
+ for (const payload of attachedOptionPayloads(t))
1107
+ judgeOperand(payload, argGlobs(k));
1083
1108
  continue;
1084
1109
  }
1085
1110
  }
@@ -1099,10 +1124,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1099
1124
  if (isGrepPatternSlot)
1100
1125
  continue;
1101
1126
  positionalOperands.push(t);
1102
- if (isPathShapedToken(t))
1103
- candidates.push({ text: t, globbed: argGlobs(k) });
1104
- else
1105
- bareWordOperands.push({ text: t, globbed: argGlobs(k) });
1127
+ judgeOperand(t, argGlobs(k));
1106
1128
  }
1107
1129
  }
1108
1130
  for (let k = 0; k < args.length; k++) {
@@ -1206,7 +1228,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1206
1228
  const split = segmentCompoundForReadonly(command, boundary?.homeDir);
1207
1229
  if ("reject" in split)
1208
1230
  return { reason: split.reject, refusedWhole: true };
1209
- const { segments, pipeFed } = split;
1231
+ const { segments, pipeFed, connectors } = split;
1210
1232
  let nameRefusal;
1211
1233
  const unlistedSegments = new Set();
1212
1234
  const reheaded = new Map();
@@ -1248,6 +1270,9 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1248
1270
  if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
1249
1271
  return { reason: `"${written}" runs "${unwrapped.head}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1250
1272
  }
1273
+ if (argvReentryUnjudged(unwrapped)) {
1274
+ return { reason: `"${written}" takes the operands of the program it runs from its standard input, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1275
+ }
1251
1276
  if (unwrapped.kind === "unlisted")
1252
1277
  unlistedSegments.add(si);
1253
1278
  else
@@ -1286,13 +1311,29 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1286
1311
  };
1287
1312
  const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
1288
1313
  const foldedSegments = [];
1314
+ const foldedReach = [];
1315
+ let crossedAllAnd = true;
1316
+ let crossedAnyOr = false;
1289
1317
  let shapeRefusal = nameRefusal;
1290
1318
  for (let si = 0; si < segments.length; si++) {
1319
+ if (si > 0) {
1320
+ if (connectors[si] !== "&&")
1321
+ crossedAllAnd = false;
1322
+ if (connectors[si] === "||")
1323
+ crossedAnyOr = true;
1324
+ }
1325
+ const moveProvable = pipeFed[si] !== true && pipeFed[si + 1] !== true && reheaded.get(si) === undefined;
1326
+ const pushReach = () => {
1327
+ foldedReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
1328
+ crossedAllAnd = true;
1329
+ crossedAnyOr = false;
1330
+ };
1291
1331
  if (unlistedSegments.has(si)) {
1292
1332
  const unlisted = tokenizeSegment(segments[si]);
1293
1333
  if (unlisted.folded.length === 0)
1294
1334
  continue;
1295
1335
  foldedSegments.push({ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded], raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw] });
1336
+ pushReach();
1296
1337
  continue;
1297
1338
  }
1298
1339
  const rehead = reheaded.get(si);
@@ -1301,6 +1342,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1301
1342
  if (toks.length === 0)
1302
1343
  continue;
1303
1344
  foldedSegments.push(segmentTokens);
1345
+ pushReach();
1304
1346
  const name = toks[0];
1305
1347
  const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
1306
1348
  if (guardReason !== undefined) {
@@ -1381,7 +1423,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1381
1423
  shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
1382
1424
  }
1383
1425
  if (boundary !== undefined) {
1384
- const walk = evaluateReadBoundary(foldedSegments, boundary);
1426
+ const walk = evaluateReadBoundary(foldedSegments, boundary, foldedReach);
1385
1427
  if (shapeRefusal === undefined)
1386
1428
  return walk;
1387
1429
  const { reason: _walkSentence, ...structure } = walk;
@@ -1394,7 +1436,9 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1394
1436
  }
1395
1437
  return {};
1396
1438
  }
1397
- function evaluateReadBoundary(foldedSegments, boundary) {
1439
+ const BASE_CANDIDATE_MAX = 256;
1440
+ function evaluateReadBoundary(foldedSegments, boundary, reach) {
1441
+ const seen = { outside: new Set(), inside: new Set(), undecided: new Set(), recursive: new Set(), unresolved: new Set() };
1398
1442
  const outside = [];
1399
1443
  const inside = [];
1400
1444
  let unresolvable;
@@ -1402,60 +1446,120 @@ function evaluateReadBoundary(foldedSegments, boundary) {
1402
1446
  let baseLost = false;
1403
1447
  const undecided = [];
1404
1448
  const recursive = [];
1405
- let currentBase = boundary.cwd ?? boundary.roots[0];
1406
- for (const toks of foldedSegments) {
1449
+ let candidates = [{ base: boundary.cwd ?? boundary.roots[0], skippedCd: -1 }];
1450
+ let andRunStart = 0;
1451
+ const findingsByWords = new Map();
1452
+ const findingsRowFor = (wordsKey) => {
1453
+ const row = findingsByWords.get(wordsKey);
1454
+ if (row !== undefined)
1455
+ return row;
1456
+ const fresh = new Map();
1457
+ findingsByWords.set(wordsKey, fresh);
1458
+ return fresh;
1459
+ };
1460
+ const findingsFor = (row, toks, base) => {
1461
+ const hit = row.get(base);
1462
+ if (hit !== undefined)
1463
+ return hit;
1464
+ const segBoundary = base === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: base };
1465
+ const computed = collectSegmentBoundaryFindings(toks, segBoundary);
1466
+ row.set(base, computed);
1467
+ return computed;
1468
+ };
1469
+ for (let si = 0; si < foldedSegments.length; si++) {
1470
+ const toks = foldedSegments[si];
1407
1471
  if (baseLost)
1408
1472
  break;
1409
- const segBoundary = currentBase === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: currentBase };
1410
- for (const finding of collectSegmentBoundaryFindings(toks, segBoundary)) {
1411
- if (finding.kind === "unresolvable") {
1412
- unresolvable ??= finding.reason;
1413
- if (!unresolved.includes(finding.operand))
1414
- unresolved.push(finding.operand);
1415
- if (toks.folded[0] === "cd")
1416
- baseLost = true;
1417
- continue;
1418
- }
1419
- const denied = boundary.denyMatch?.(finding.path);
1420
- if (denied != null) {
1421
- return {
1422
- reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
1423
- readDenied: true,
1424
- };
1425
- }
1426
- if (finding.kind === "inside") {
1427
- if (!inside.includes(finding.path))
1428
- inside.push(finding.path);
1429
- continue;
1430
- }
1431
- if (finding.kind === "undecided") {
1432
- if (!undecided.includes(finding.path))
1433
- undecided.push(finding.path);
1434
- continue;
1435
- }
1436
- if (finding.kind === "recursive") {
1437
- if (!recursive.includes(finding.path))
1438
- recursive.push(finding.path);
1439
- continue;
1440
- }
1441
- if (boundary.face === "open") {
1442
- if (!inside.includes(finding.path))
1443
- inside.push(finding.path);
1444
- continue;
1473
+ if (si > 0 && reach?.[si]?.andJoined !== true)
1474
+ andRunStart = reach?.[si]?.orBroken === true ? si + 1 : si;
1475
+ const reachable = candidates.filter((c) => c.skippedCd < andRunStart);
1476
+ const row = findingsRowFor(JSON.stringify([toks.folded, toks.raw]));
1477
+ for (const { base: currentBase } of reachable) {
1478
+ for (const finding of findingsFor(row, toks, currentBase)) {
1479
+ if (finding.kind === "unresolvable") {
1480
+ unresolvable ??= finding.reason;
1481
+ if (!seen.unresolved.has(finding.operand)) {
1482
+ seen.unresolved.add(finding.operand);
1483
+ unresolved.push(finding.operand);
1484
+ }
1485
+ if (toks.folded[0] === "cd")
1486
+ baseLost = true;
1487
+ continue;
1488
+ }
1489
+ const denied = boundary.denyMatch?.(finding.path);
1490
+ if (denied != null) {
1491
+ return {
1492
+ reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
1493
+ readDenied: true,
1494
+ };
1495
+ }
1496
+ if (finding.kind === "inside") {
1497
+ if (!seen.inside.has(finding.path)) {
1498
+ seen.inside.add(finding.path);
1499
+ inside.push(finding.path);
1500
+ }
1501
+ continue;
1502
+ }
1503
+ if (finding.kind === "undecided") {
1504
+ if (!seen.undecided.has(finding.path)) {
1505
+ seen.undecided.add(finding.path);
1506
+ undecided.push(finding.path);
1507
+ }
1508
+ continue;
1509
+ }
1510
+ if (finding.kind === "recursive") {
1511
+ if (!seen.recursive.has(finding.path)) {
1512
+ seen.recursive.add(finding.path);
1513
+ recursive.push(finding.path);
1514
+ }
1515
+ continue;
1516
+ }
1517
+ if (boundary.face === "open") {
1518
+ if (!seen.inside.has(finding.path)) {
1519
+ seen.inside.add(finding.path);
1520
+ inside.push(finding.path);
1521
+ }
1522
+ continue;
1523
+ }
1524
+ if (!seen.outside.has(finding.path)) {
1525
+ seen.outside.add(finding.path);
1526
+ outside.push(finding);
1527
+ }
1445
1528
  }
1446
- if (!outside.some((o) => o.path === finding.path))
1447
- outside.push(finding);
1448
1529
  }
1449
1530
  if (!baseLost && toks.folded[0] === "cd") {
1450
1531
  const cdArgs = toks.folded.slice(1);
1451
1532
  const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
1452
1533
  const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
1453
1534
  if (cdTarget !== undefined && cdTarget !== "-") {
1454
- currentBase = resolveOperandLexically(currentBase, cdTarget, boundary.homeDir) ?? currentBase;
1535
+ const provable = reach?.[si]?.moveProvable === true;
1536
+ const branched = [];
1537
+ for (const c of candidates) {
1538
+ if (c.skippedCd < andRunStart)
1539
+ branched.push({ base: resolveOperandLexically(c.base, cdTarget, boundary.homeDir) ?? c.base, skippedCd: c.skippedCd });
1540
+ branched.push({ base: c.base, skippedCd: provable ? si : c.skippedCd });
1541
+ }
1542
+ const byBase = new Map();
1543
+ for (const c of branched) {
1544
+ const prior = byBase.get(c.base);
1545
+ if (prior === undefined || c.skippedCd < prior.skippedCd)
1546
+ byBase.set(c.base, c);
1547
+ }
1548
+ const next = [...byBase.values()];
1549
+ if (next.length > BASE_CANDIDATE_MAX) {
1550
+ unresolvable ??= `too many directory changes to track where "${cdTarget}" leaves the working directory ${NOT_AUTO_ALLOWED}`;
1551
+ if (!seen.unresolved.has(cdTarget)) {
1552
+ seen.unresolved.add(cdTarget);
1553
+ unresolved.push(cdTarget);
1554
+ }
1555
+ baseLost = true;
1556
+ }
1557
+ else
1558
+ candidates = next;
1455
1559
  }
1456
1560
  }
1457
1561
  }
1458
- const undecidedAll = [...undecided, ...recursive.filter((p) => !undecided.includes(p))];
1562
+ const undecidedAll = [...undecided, ...recursive.filter((p) => !seen.undecided.has(p))];
1459
1563
  const undecidedField = {
1460
1564
  ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
1461
1565
  ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
@@ -1502,7 +1606,7 @@ const COMMAND_LAUNCHERS = new Set([
1502
1606
  "env", "command", "builtin", "exec", "noglob", "xargs",
1503
1607
  "sudo", "doas", "pkexec", "su", "runuser", "chroot", "setpriv",
1504
1608
  "nohup", "nice", "ionice", "chrt", "taskset", "stdbuf", "timeout", "time", "watch",
1505
- "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run", "parallel",
1609
+ "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run",
1506
1610
  "strace", "ltrace", "valgrind", "firejail", "bwrap",
1507
1611
  ]);
1508
1612
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
@@ -1522,8 +1626,25 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1522
1626
  if ("reject" in split)
1523
1627
  return NOT_GATED;
1524
1628
  const readers = [];
1629
+ const readerReach = [];
1630
+ let crossedAllAnd = true;
1631
+ let crossedAnyOr = false;
1525
1632
  let baseMoved = false;
1526
- for (const segment of split.segments) {
1633
+ for (let si = 0; si < split.segments.length; si++) {
1634
+ const segment = split.segments[si];
1635
+ if (si > 0) {
1636
+ if (split.connectors[si] !== "&&")
1637
+ crossedAllAnd = false;
1638
+ if (split.connectors[si] === "||")
1639
+ crossedAnyOr = true;
1640
+ }
1641
+ const moveProvable = split.pipeFed[si] !== true && split.pipeFed[si + 1] !== true;
1642
+ const pushReader = (st) => {
1643
+ readers.push(st);
1644
+ readerReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
1645
+ crossedAllAnd = true;
1646
+ crossedAnyOr = false;
1647
+ };
1527
1648
  const toks = tokenizeSegment(segment);
1528
1649
  if (toks.folded.length === 0)
1529
1650
  continue;
@@ -1537,16 +1658,16 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1537
1658
  if (UNMODELLED_BASE_MOVERS.has(name))
1538
1659
  baseMoved = true;
1539
1660
  if (!allow.has(name) && name !== "cd") {
1540
- readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1661
+ pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1541
1662
  continue;
1542
1663
  }
1543
- readers.push(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
1664
+ pushReader(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
1544
1665
  }
1545
1666
  if (readers.length === 0)
1546
1667
  return NOT_GATED;
1547
1668
  if (baseMoved)
1548
1669
  return { gated: true, paths: [] };
1549
- const verdict = evaluateReadBoundary(readers, containmentOnly);
1670
+ const verdict = evaluateReadBoundary(readers, containmentOnly, readerReach);
1550
1671
  return verdict.outOfRootRead === true ? { gated: true, paths: verdict.outOfRootPaths ?? [] } : NOT_GATED;
1551
1672
  };
1552
1673
  const direct = judge(command);
@@ -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). */
@@ -615,6 +615,25 @@ 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 ?? []))];
635
+ return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal, patterns: [] }, resolvedBoundary.roots, FULL_SHELL_FACE_ESCAPE_PROSE, signal);
636
+ };
618
637
  return defineTool({
619
638
  name: "Bash",
620
639
  ...toolFace("bash"),
@@ -650,6 +669,9 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
650
669
  const timeoutRefusal = bashTimeoutArgRefusal(timeout, timeoutCaps);
651
670
  if (timeoutRefusal !== undefined)
652
671
  return errorResult(timeoutRefusal, { type: "bash_invalid_timeout", code: "bash_invalid_timeout" });
672
+ const escape = await canonicalEscape(command, ctx.signal);
673
+ if (escape !== undefined)
674
+ return escape;
653
675
  if (run_in_background) {
654
676
  if (!hasBackgroundShell(env)) {
655
677
  return errorResult("Error (Bash): this environment does not support background processes (run_in_background).");
@@ -659,6 +681,8 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
659
681
  if (diagnosis !== undefined)
660
682
  throw new Error(diagnosis);
661
683
  }
684
+ if (ctx.signal?.aborted === true)
685
+ return errorResult("Error (Bash): the call was cancelled before the background command started.");
662
686
  const requestedTimeoutSec = timeout !== undefined ? Math.max(1, Math.round(timeout / 1000)) : undefined;
663
687
  const r = await env.spawnBackground(command, {
664
688
  cwd: cwdRef.current,
@@ -923,6 +947,8 @@ function createCanonicalReadBoundary(env, readRoots) {
923
947
  while (parts.length > 0) {
924
948
  const canon = await env.canonicalPath(parts.join("/") || "/", signal);
925
949
  if (canon.ok) {
950
+ if (tail.length === 0)
951
+ return canon.value;
926
952
  return [canon.value.replace(/[\\/]+$/, ""), ...[...tail].reverse()].join("/");
927
953
  }
928
954
  tail.push(parts.pop());
@@ -1045,6 +1071,29 @@ function createCanonicalReadBoundary(env, readRoots) {
1045
1071
  },
1046
1072
  };
1047
1073
  }
1074
+ async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, signal) {
1075
+ const resolved = await canonical.escapesAfterResolution(candidates, signal);
1076
+ if (resolved.unverifiable !== undefined) {
1077
+ return errorResult(`Error (Bash): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1078
+ face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: [] });
1079
+ }
1080
+ if (resolved.escaping.length > 0) {
1081
+ const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
1082
+ return errorResult(`Error (Bash): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1083
+ face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: resolved.escaping });
1084
+ }
1085
+ return undefined;
1086
+ }
1087
+ const READONLY_FACE_ESCAPE_PROSE = {
1088
+ subject: "a path this command reads",
1089
+ verdictPhrase: ` ${NOT_AUTO_ALLOWED}`,
1090
+ note: BASH_READONLY_CONFINEMENT_NOTE,
1091
+ };
1092
+ const FULL_SHELL_FACE_ESCAPE_PROSE = {
1093
+ subject: "a path this command names",
1094
+ verdictPhrase: "",
1095
+ 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.",
1096
+ };
1048
1097
  export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1049
1098
  const timeoutCaps = resolveBashTimeoutCaps(opts);
1050
1099
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
@@ -1092,16 +1141,9 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1092
1141
  return errorResult(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
1093
1142
  }
1094
1143
  }
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
- }
1144
+ const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: boundary.checkedPaths ?? [], patterns: boundary.undecidedPaths ?? [] }, readRoots, READONLY_FACE_ESCAPE_PROSE, ctx.signal);
1145
+ if (escape !== undefined)
1146
+ return escape;
1105
1147
  return await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, true);
1106
1148
  },
1107
1149
  });
@@ -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 } : {}),