@lousy-agents/agent-shell 5.14.9 → 5.14.10

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 (2) hide show
  1. package/dist/index.js +106 -87
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -275,6 +275,9 @@ function path_isPathInside(root, target) {
275
275
  const firstSegment = relative.split(external_node_path_namespaceObject.posix.sep)[0];
276
276
  return relative === "" || (firstSegment !== ".." && !external_node_path_namespaceObject.isAbsolute(relative));
277
277
  }
278
+ function isPathRelativeEscape(relativePath) {
279
+ return relativePath === ".." || relativePath.startsWith(`..${external_node_path_namespaceObject.sep}`) || external_node_path_namespaceObject.isAbsolute(relativePath);
280
+ }
278
281
  function resolveSafeBaseDir(rootDir) {
279
282
  const resolved = path.resolve(rootDir);
280
283
  return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
@@ -423,17 +426,17 @@ function directory_guard_createNearestExistingSyncDirectoryGuard(rootReal, targe
423
426
 
424
427
 
425
428
 
429
+
426
430
  function isSameOrChildPath(candidate, parent) {
427
- return candidate === parent || candidate.startsWith(`${parent}${external_node_path_namespaceObject.sep}`);
428
- }
429
- function isPathEscape(relativePath) {
430
- return relativePath === ".." || relativePath.startsWith(`..${external_node_path_namespaceObject.sep}`) || external_node_path_namespaceObject.isAbsolute(relativePath);
431
+ const parentPrefix = parent.endsWith(external_node_path_namespaceObject.sep) ? parent : `${parent}${external_node_path_namespaceObject.sep}`;
432
+ return candidate === parent || candidate.startsWith(parentPrefix);
431
433
  }
432
434
  async function mkdirPathComponentsWithGuards(params) {
433
435
  const root = external_node_path_namespaceObject.resolve(params.rootReal);
436
+ const rootCanonical = external_node_path_namespaceObject.resolve(await promises_namespaceObject.realpath(root));
434
437
  const target = external_node_path_namespaceObject.resolve(params.targetPath);
435
438
  const relative = external_node_path_namespaceObject.relative(root, target);
436
- if (isPathEscape(relative)) {
439
+ if (isPathRelativeEscape(relative)) {
437
440
  throw new errors_FsSafeError("outside-workspace", "directory is outside workspace root");
438
441
  }
439
442
  let current = root;
@@ -456,7 +459,7 @@ async function mkdirPathComponentsWithGuards(params) {
456
459
  }
457
460
  // Node's recursive mkdir follows symlinks in missing components. Build one
458
461
  // segment at a time and realpath-check each segment before descending.
459
- if (!isSameOrChildPath(external_node_path_namespaceObject.resolve(await promises_namespaceObject.realpath(next)), root)) {
462
+ if (!isSameOrChildPath(external_node_path_namespaceObject.resolve(await promises_namespaceObject.realpath(next)), rootCanonical)) {
460
463
  throw new errors_FsSafeError("outside-workspace", "directory escaped workspace root");
461
464
  }
462
465
  await directory_guard_createAsyncDirectoryGuard(next);
@@ -589,26 +592,20 @@ function canFallbackFromPythonError(error) {
589
592
 
590
593
 
591
594
  const PINNED_PYTHON_WORKER_SOURCE = String.raw `
592
- import base64
593
- import errno
594
- import json
595
- import os
596
- import secrets
597
- import stat
598
- import sys
599
-
595
+ import base64, errno, json, os, secrets, stat, sys
600
596
  DIR_FLAGS = os.O_RDONLY
601
597
  if hasattr(os, "O_DIRECTORY"):
602
598
  DIR_FLAGS |= os.O_DIRECTORY
603
599
  if hasattr(os, "O_NOFOLLOW"):
604
600
  DIR_FLAGS |= os.O_NOFOLLOW
605
601
  READ_FLAGS = os.O_RDONLY
602
+ if hasattr(os, "O_NONBLOCK"):
603
+ READ_FLAGS |= os.O_NONBLOCK
606
604
  if hasattr(os, "O_NOFOLLOW"):
607
605
  READ_FLAGS |= os.O_NOFOLLOW
608
606
  WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
609
607
  if hasattr(os, "O_NOFOLLOW"):
610
608
  WRITE_FLAGS |= os.O_NOFOLLOW
611
-
612
609
  def split_relative(value):
613
610
  if value in ("", "."):
614
611
  return []
@@ -621,10 +618,8 @@ def split_relative(value):
621
618
  if part == "..":
622
619
  raise OSError(errno.EPERM, "path traversal is not allowed")
623
620
  return parts
624
-
625
621
  def open_dir(path_value, dir_fd=None):
626
622
  return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)
627
-
628
623
  def walk_dir(root_fd, segments, mkdir_enabled=False):
629
624
  current_fd = os.dup(root_fd)
630
625
  try:
@@ -642,14 +637,12 @@ def walk_dir(root_fd, segments, mkdir_enabled=False):
642
637
  except Exception:
643
638
  os.close(current_fd)
644
639
  raise
645
-
646
640
  def parent_and_basename(root_fd, relative):
647
641
  segments = split_relative(relative)
648
642
  if not segments:
649
643
  raise OSError(errno.EPERM, "operation requires a non-root path")
650
644
  parent_fd = walk_dir(root_fd, segments[:-1])
651
645
  return parent_fd, segments[-1]
652
-
653
646
  def encode_stat(st):
654
647
  mode = st.st_mode
655
648
  return {
@@ -665,14 +658,12 @@ def encode_stat(st):
665
658
  "size": st.st_size,
666
659
  "uid": st.st_uid,
667
660
  }
668
-
669
661
  def reject_unsafe_endpoint(st):
670
662
  mode = st.st_mode
671
663
  if stat.S_ISLNK(mode):
672
664
  raise OSError(errno.ELOOP, "symlink endpoint is not allowed")
673
665
  if stat.S_ISREG(mode) and st.st_nlink > 1:
674
666
  raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed")
675
-
676
667
  def copy_bytes(source_fd, dest_fd):
677
668
  while True:
678
669
  chunk = os.read(source_fd, 65536)
@@ -684,7 +675,6 @@ def copy_bytes(source_fd, dest_fd):
684
675
  if written <= 0:
685
676
  raise OSError(errno.EIO, "short write")
686
677
  view = view[written:]
687
-
688
678
  def write_all(fd, data):
689
679
  view = memoryview(data)
690
680
  while view:
@@ -692,11 +682,9 @@ def write_all(fd, data):
692
682
  if written <= 0:
693
683
  raise OSError(errno.EIO, "short write")
694
684
  view = view[written:]
695
-
696
685
  def link_unsupported(exc):
697
686
  unsupported = (errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP))
698
687
  return getattr(exc, "errno", None) in unsupported
699
-
700
688
  def link_no_replace(name, new_name, source_fd, target_fd):
701
689
  linked = False
702
690
  try:
@@ -711,11 +699,9 @@ def link_no_replace(name, new_name, source_fd, target_fd):
711
699
  os.fsync(source_fd)
712
700
  if source_fd != target_fd:
713
701
  os.fsync(target_fd)
714
-
715
702
  def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basename, mode, expected=None, unlink_source=False):
716
703
  source_fd = os.open(source_name, READ_FLAGS, dir_fd=source_parent_fd)
717
- dest_fd = None
718
- success = False
704
+ dest_fd = None; success = False; dest_stat = None
719
705
  try:
720
706
  if expected is not None:
721
707
  source_stat = os.fstat(source_fd)
@@ -724,6 +710,7 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
724
710
  dest_fd = os.open(basename, WRITE_FLAGS, mode, dir_fd=target_parent_fd)
725
711
  copy_bytes(source_fd, dest_fd)
726
712
  os.fsync(dest_fd)
713
+ dest_stat = os.fstat(dest_fd)
727
714
  success = True
728
715
  finally:
729
716
  os.close(source_fd)
@@ -739,19 +726,40 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
739
726
  try: os.unlink(basename, dir_fd=target_parent_fd)
740
727
  except FileNotFoundError: pass
741
728
  raise
742
-
743
- def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode):
729
+ return dest_stat
730
+ def same_identity(left, right):
731
+ return left.st_dev == right.st_dev and left.st_ino == right.st_ino
732
+ def verify_temp_name(parent_fd, temp_name, expected_stat):
733
+ current_stat = os.lstat(temp_name, dir_fd=parent_fd)
734
+ if stat.S_ISLNK(current_stat.st_mode) or not same_identity(current_stat, expected_stat):
735
+ raise RuntimeError("fs-safe-temp-mismatch")
736
+ def verify_committed_temp(parent_fd, basename, expected_stat):
737
+ final_stat = os.lstat(basename, dir_fd=parent_fd)
738
+ if not stat.S_ISLNK(final_stat.st_mode) and same_identity(final_stat, expected_stat):
739
+ return final_stat
740
+ try: os.unlink(basename, dir_fd=parent_fd)
741
+ except FileNotFoundError: pass
742
+ raise RuntimeError("fs-safe-temp-mismatch")
743
+ def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, expected_stat):
744
+ verify_temp_name(parent_fd, temp_name, expected_stat)
744
745
  if overwrite:
745
746
  os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
747
+ return verify_committed_temp(parent_fd, basename, expected_stat)
746
748
  else:
747
749
  try:
748
750
  os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)
751
+ final_stat = verify_committed_temp(parent_fd, basename, expected_stat)
749
752
  os.unlink(temp_name, dir_fd=parent_fd)
753
+ return final_stat
750
754
  except OSError as exc:
751
755
  if not link_unsupported(exc):
752
756
  raise
753
- copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, unlink_source=True)
754
-
757
+ return copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, expected_stat, True)
758
+ def assert_expected_root(root_fd, payload):
759
+ if "rootDev" in payload or "rootIno" in payload:
760
+ root_stat = os.fstat(root_fd)
761
+ if root_stat.st_dev != int(payload["rootDev"]) or root_stat.st_ino != int(payload["rootIno"]):
762
+ raise RuntimeError("fs-safe-root-mismatch")
755
763
  def stat_path(root_fd, payload):
756
764
  relative = payload.get("relativePath", "")
757
765
  segments = split_relative(relative)
@@ -765,7 +773,6 @@ def stat_path(root_fd, payload):
765
773
  return encode_stat(st)
766
774
  finally:
767
775
  os.close(parent_fd)
768
-
769
776
  def readdir_path(root_fd, payload):
770
777
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")))
771
778
  try:
@@ -781,12 +788,9 @@ def readdir_path(root_fd, payload):
781
788
  return entries
782
789
  finally:
783
790
  os.close(dir_fd)
784
-
785
791
  def mkdirp_path(root_fd, payload):
786
792
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")), mkdir_enabled=True)
787
- os.close(dir_fd)
788
- return None
789
-
793
+ os.close(dir_fd); return None
790
794
  def remove_tree(parent_fd, basename):
791
795
  st = os.lstat(basename, dir_fd=parent_fd)
792
796
  if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode):
@@ -799,7 +803,6 @@ def remove_tree(parent_fd, basename):
799
803
  os.rmdir(basename, dir_fd=parent_fd)
800
804
  else:
801
805
  os.unlink(basename, dir_fd=parent_fd)
802
-
803
806
  def remove_path(root_fd, payload):
804
807
  parent_fd, basename = parent_and_basename(root_fd, payload.get("relativePath", ""))
805
808
  try:
@@ -882,14 +885,15 @@ def write_path(root_fd, payload):
882
885
  except FileNotFoundError:
883
886
  pass
884
887
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
888
+ os.fchmod(temp_fd, mode)
885
889
  write_all(temp_fd, data)
886
890
  os.fsync(temp_fd)
891
+ temp_stat = os.fstat(temp_fd)
887
892
  os.close(temp_fd)
888
893
  temp_fd = None
889
- commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
894
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
890
895
  temp_name = None
891
896
  os.fsync(parent_fd)
892
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
893
897
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
894
898
  finally:
895
899
  if temp_fd is not None:
@@ -920,6 +924,7 @@ def copy_path(root_fd, payload):
920
924
  raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size))
921
925
  parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True)))
922
926
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
927
+ os.fchmod(temp_fd, mode)
923
928
  written_bytes = 0
924
929
  while True:
925
930
  chunk = os.read(source_fd, 65536)
@@ -935,12 +940,12 @@ def copy_path(root_fd, payload):
935
940
  raise OSError(errno.EIO, "short write")
936
941
  view = view[written:]
937
942
  os.fsync(temp_fd)
943
+ temp_stat = os.fstat(temp_fd)
938
944
  os.close(temp_fd)
939
945
  temp_fd = None
940
- commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
946
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
941
947
  temp_name = None
942
948
  os.fsync(parent_fd)
943
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
944
949
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
945
950
  finally:
946
951
  os.close(source_fd)
@@ -957,6 +962,7 @@ def copy_path(root_fd, payload):
957
962
  def run_operation(operation, root_path, payload):
958
963
  root_fd = open_dir(root_path)
959
964
  try:
965
+ assert_expected_root(root_fd, payload)
960
966
  if operation == "stat":
961
967
  return stat_path(root_fd, payload)
962
968
  if operation == "readdir":
@@ -1061,6 +1067,12 @@ function mapWorkerError(response) {
1061
1067
  if (message.includes("fs-safe-source-mismatch")) {
1062
1068
  return new errors_FsSafeError("path-mismatch", "source path changed during copy");
1063
1069
  }
1070
+ if (message.includes("fs-safe-temp-mismatch")) {
1071
+ return new errors_FsSafeError("path-mismatch", "temp path changed during write");
1072
+ }
1073
+ if (message.includes("fs-safe-root-mismatch")) {
1074
+ return new errors_FsSafeError("path-mismatch", "root path changed during operation");
1075
+ }
1064
1076
  if (message.includes("fs-safe-directory-noreplace-unsupported")) {
1065
1077
  return new errors_FsSafeError("invalid-path", "directory moves require overwrite: true");
1066
1078
  }
@@ -1241,9 +1253,7 @@ function validatePinnedOperationPayload(payload) {
1241
1253
  }
1242
1254
  }
1243
1255
  function isPinnedHelperUnavailable(error) {
1244
- return error instanceof Error &&
1245
- "code" in error &&
1246
- error.code === "helper-unavailable";
1256
+ return error instanceof Error && "code" in error && error.code === "helper-unavailable";
1247
1257
  }
1248
1258
  function validatePinnedRelativePath(relativePath) {
1249
1259
  if (relativePath.length === 0 || relativePath === ".") {
@@ -1341,29 +1351,21 @@ function assertWithinMaxBytes(bytes, maxBytes) {
1341
1351
  throw new errors_FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`);
1342
1352
  }
1343
1353
  }
1344
- function pinned_write_createMaxBytesTransform(maxBytes) {
1345
- if (maxBytes === undefined) {
1346
- return undefined;
1347
- }
1354
+ async function writeStreamToHandle(stream, handle, maxBytes) {
1348
1355
  let bytes = 0;
1349
- return new external_node_stream_namespaceObject.Transform({
1350
- transform(chunk, _encoding, callback) {
1351
- bytes += chunk.byteLength;
1352
- if (bytes > maxBytes) {
1353
- callback(new errors_FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`));
1354
- return;
1356
+ for await (const chunk of stream) {
1357
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1358
+ bytes += buffer.byteLength;
1359
+ assertWithinMaxBytes(bytes, maxBytes);
1360
+ let offset = 0;
1361
+ while (offset < buffer.byteLength) {
1362
+ const { bytesWritten } = await handle.write(buffer, offset, buffer.byteLength - offset);
1363
+ if (bytesWritten <= 0) {
1364
+ throw new errors_FsSafeError("helper-failed", "fallback stream write made no progress");
1355
1365
  }
1356
- callback(null, chunk);
1357
- },
1358
- });
1359
- }
1360
- async function pipelineWithMaxBytes(stream, destination, maxBytes) {
1361
- const limiter = pinned_write_createMaxBytesTransform(maxBytes);
1362
- if (limiter) {
1363
- await (0,external_node_stream_promises_namespaceObject.pipeline)(stream, limiter, destination);
1364
- return;
1366
+ offset += bytesWritten;
1367
+ }
1365
1368
  }
1366
- await (0,external_node_stream_promises_namespaceObject.pipeline)(stream, destination);
1367
1369
  }
1368
1370
  async function inputToBase64(input, maxBytes) {
1369
1371
  if (input.kind === "buffer") {
@@ -1388,7 +1390,7 @@ async function runPinnedWriteHelper(params) {
1388
1390
  relativeParentPath: params.relativeParentPath,
1389
1391
  });
1390
1392
  if (getFsSafePythonConfig().mode === "off") {
1391
- return await runPinnedWriteFallback(params);
1393
+ return await runPinnedWriteFallbackOrThrow(params);
1392
1394
  }
1393
1395
  if (params.input.kind === "stream") {
1394
1396
  try {
@@ -1396,7 +1398,7 @@ async function runPinnedWriteHelper(params) {
1396
1398
  }
1397
1399
  catch (error) {
1398
1400
  if (canFallbackFromPythonError(error)) {
1399
- return await runPinnedWriteFallback(params);
1401
+ return await runPinnedWriteFallbackOrThrow(params, error);
1400
1402
  }
1401
1403
  throw error;
1402
1404
  }
@@ -1409,6 +1411,7 @@ async function runPinnedWriteHelper(params) {
1409
1411
  mode: params.mode || 0o600,
1410
1412
  overwrite: params.overwrite !== false,
1411
1413
  relativeParentPath: params.relativeParentPath,
1414
+ ...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
1412
1415
  };
1413
1416
  try {
1414
1417
  return await runPinnedPythonOperation({
@@ -1419,7 +1422,7 @@ async function runPinnedWriteHelper(params) {
1419
1422
  }
1420
1423
  catch (error) {
1421
1424
  if (canFallbackFromPythonError(error)) {
1422
- return await runPinnedWriteFallback(params);
1425
+ return await runPinnedWriteFallbackOrThrow(params, error);
1423
1426
  }
1424
1427
  throw error;
1425
1428
  }
@@ -1439,22 +1442,29 @@ async function runPinnedCopyHelper(params) {
1439
1442
  mode: params.mode || 0o600,
1440
1443
  overwrite: params.overwrite !== false,
1441
1444
  relativeParentPath: params.relativeParentPath,
1445
+ ...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
1442
1446
  sourceDev: params.sourceIdentity.dev,
1443
1447
  sourceIno: params.sourceIdentity.ino,
1444
1448
  sourcePath: params.sourcePath,
1445
1449
  },
1446
1450
  });
1447
1451
  }
1452
+ async function runPinnedWriteFallbackOrThrow(params, cause) {
1453
+ if (process.platform !== "win32") {
1454
+ throw new errors_FsSafeError("helper-unavailable", "Python helper is required for pinned writes on this platform", { cause });
1455
+ }
1456
+ return await runPinnedWriteFallback(params);
1457
+ }
1448
1458
  async function runPinnedWriteFallback(params) {
1449
1459
  const parentPath = params.relativeParentPath
1450
1460
  ? external_node_path_namespaceObject.join(params.rootPath, ...params.relativeParentPath.split("/"))
1451
1461
  : params.rootPath;
1452
- const parentGuard = await directory_guard_createNearestExistingDirectoryGuard(params.rootPath, parentPath);
1453
1462
  if (params.mkdir) {
1454
- await withAsyncDirectoryGuards([parentGuard], async () => {
1455
- await promises_namespaceObject.mkdir(parentPath, { recursive: true });
1456
- });
1463
+ await mkdirPathComponentsWithGuards({ rootReal: params.rootPath, targetPath: parentPath });
1457
1464
  }
1465
+ const parentGuard = params.mkdir
1466
+ ? await directory_guard_createAsyncDirectoryGuard(parentPath)
1467
+ : await directory_guard_createNearestExistingDirectoryGuard(params.rootPath, parentPath);
1458
1468
  const targetPath = external_node_path_namespaceObject.join(parentPath, params.basename);
1459
1469
  if (params.overwrite === false) {
1460
1470
  let handle = await withAsyncDirectoryGuards([parentGuard], async () => await promises_namespaceObject.open(targetPath, external_node_fs_namespaceObject.constants.O_WRONLY | external_node_fs_namespaceObject.constants.O_CREAT | external_node_fs_namespaceObject.constants.O_EXCL, params.mode), {
@@ -1476,7 +1486,7 @@ async function runPinnedWriteFallback(params) {
1476
1486
  }
1477
1487
  }
1478
1488
  else {
1479
- await pipelineWithMaxBytes(params.input.stream, handle.createWriteStream(), params.maxBytes);
1489
+ await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
1480
1490
  }
1481
1491
  const stat = await handle.stat();
1482
1492
  created = false;
@@ -1497,7 +1507,9 @@ async function runPinnedWriteFallback(params) {
1497
1507
  ? external_node_fs_namespaceObject.constants.O_NOFOLLOW
1498
1508
  : 0);
1499
1509
  let handle;
1500
- let handleClosedByStream = false;
1510
+ let tempStat;
1511
+ let targetStat;
1512
+ let renamed = false;
1501
1513
  try {
1502
1514
  handle = await promises_namespaceObject.open(tempPath, tempFlags, params.mode);
1503
1515
  if (params.input.kind === "buffer") {
@@ -1510,29 +1522,36 @@ async function runPinnedWriteFallback(params) {
1510
1522
  }
1511
1523
  }
1512
1524
  else {
1513
- const writable = handle.createWriteStream();
1514
- writable.once("close", () => {
1515
- handleClosedByStream = true;
1516
- });
1517
- await pipelineWithMaxBytes(params.input.stream, writable, params.maxBytes);
1525
+ await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
1518
1526
  }
1519
- if (!handleClosedByStream) {
1520
- await handle.close().catch(() => undefined);
1521
- handle = undefined;
1527
+ tempStat = await handle.stat();
1528
+ const tempPathStat = await promises_namespaceObject.lstat(tempPath);
1529
+ if (tempPathStat.isSymbolicLink() || !file_identity_sameFileIdentity(tempPathStat, tempStat)) {
1530
+ throw new errors_FsSafeError("path-mismatch", "fallback temp path changed during write");
1522
1531
  }
1532
+ const expectedTempStat = tempStat;
1533
+ await handle.close().catch(() => undefined);
1534
+ handle = undefined;
1523
1535
  await withAsyncDirectoryGuards([parentGuard], async () => {
1524
1536
  await promises_namespaceObject.rename(tempPath, targetPath);
1537
+ renamed = true;
1538
+ targetStat = await promises_namespaceObject.lstat(targetPath);
1539
+ if (targetStat.isSymbolicLink() || !file_identity_sameFileIdentity(targetStat, expectedTempStat)) {
1540
+ throw new errors_FsSafeError("path-mismatch", "fallback target changed during write");
1541
+ }
1525
1542
  });
1526
1543
  }
1527
1544
  catch (error) {
1528
- if (handle && !handleClosedByStream) {
1529
- await handle.close().catch(() => undefined);
1545
+ await handle?.close().catch(() => undefined);
1546
+ if (!renamed) {
1547
+ await promises_namespaceObject.rm(tempPath, { force: true }).catch(() => undefined);
1530
1548
  }
1531
- await promises_namespaceObject.rm(tempPath, { force: true }).catch(() => undefined);
1532
1549
  throw error;
1533
1550
  }
1534
- const stat = await promises_namespaceObject.stat(targetPath);
1535
- return { dev: stat.dev, ino: stat.ino };
1551
+ if (!targetStat) {
1552
+ throw new errors_FsSafeError("path-mismatch", "fallback target was not verified");
1553
+ }
1554
+ return { dev: targetStat.dev, ino: targetStat.ino };
1536
1555
  }
1537
1556
 
1538
1557
  ;// CONCATENATED MODULE: external "node:os"
@@ -2071,7 +2090,7 @@ function relativeInsideRoot(rootPath, targetPath) {
2071
2090
  if (!relative || relative === ".") {
2072
2091
  return "";
2073
2092
  }
2074
- if (relative.startsWith("..") || external_node_path_namespaceObject.isAbsolute(relative)) {
2093
+ if (isPathRelativeEscape(relative)) {
2075
2094
  return "";
2076
2095
  }
2077
2096
  return relative;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lousy-agents/agent-shell",
3
- "version": "5.14.9",
3
+ "version": "5.14.10",
4
4
  "description": "A flight recorder for npm script execution",
5
5
  "type": "module",
6
6
  "repository": {
@@ -36,7 +36,7 @@
36
36
  "build": "rspack build --config rspack.config.ts && chmod +x dist/index.js"
37
37
  },
38
38
  "dependencies": {
39
- "@openclaw/fs-safe": "0.2.5",
39
+ "@openclaw/fs-safe": "0.2.6",
40
40
  "zod": "4.4.3"
41
41
  },
42
42
  "peerDependencies": {