@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.
- package/CHANGELOG.md +41 -0
- package/dist/core/runner/prepare-hands-readface.js +10 -8
- package/dist/core/tool-catalog-entries.js +1 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +18 -5
- package/dist/tools/fs/bash-readonly-classifier.js +194 -73
- package/dist/tools/fs/fs-bash.d.ts +28 -0
- package/dist/tools/fs/fs-bash.js +52 -10
- package/dist/tools/fs/index.d.ts +13 -0
- package/dist/tools/fs/index.js +1 -0
- package/package.json +3 -2
- package/wire-changes/core@7.10.0.json +739 -0
- package/wire-changes/core@7.11.0.json +685 -0
- package/wire-changes/core@7.11.1.json +112 -0
- package/wire-changes/core@7.11.2.json +73 -0
- package/wire-changes/core@7.12.0.json +356 -0
- package/wire-changes/core@7.13.0.json +199 -0
- package/wire-changes/core@7.14.0.json +578 -0
- package/wire-changes/core@7.15.0.json +174 -0
- package/wire-changes/core@7.16.0.json +132 -0
- package/wire-changes/core@7.17.0.json +1621 -0
- package/wire-changes/core@7.17.1.json +139 -0
- package/wire-changes/core@7.17.2.json +65 -0
- package/wire-changes/core@7.17.3.json +44 -0
- package/wire-changes/core@7.17.4.json +52 -0
- package/wire-changes/core@7.17.5.json +67 -0
- package/wire-changes/core@7.5.0.json +1063 -0
- package/wire-changes/core@7.5.1.json +252 -0
- package/wire-changes/core@7.5.2.json +418 -0
- package/wire-changes/core@7.6.0.json +1243 -0
- package/wire-changes/core@7.6.1.json +1130 -0
- package/wire-changes/core@7.7.0.json +509 -0
- package/wire-changes/core@7.8.0.json +1293 -0
- package/wire-changes/core@7.9.0.json +691 -0
- package/wire-changes/core@7.9.1.json +518 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
connectors.push("||");
|
|
136
136
|
i++;
|
|
137
137
|
}
|
|
138
138
|
else {
|
|
139
139
|
segments.push(cur);
|
|
140
140
|
cur = "";
|
|
141
|
-
|
|
141
|
+
connectors.push("|");
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
else if (c === ";") {
|
|
145
145
|
segments.push(cur);
|
|
146
146
|
cur = "";
|
|
147
|
-
|
|
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)
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1406
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
if (
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
if (
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
|
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 (
|
|
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
|
-
|
|
1661
|
+
pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
|
|
1541
1662
|
continue;
|
|
1542
1663
|
}
|
|
1543
|
-
|
|
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). */
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -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
|
|
1096
|
-
if (
|
|
1097
|
-
return
|
|
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
|
});
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -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. */
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -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 } : {}),
|