@openclaw/fs-safe 0.2.7 → 0.4.0

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 (59) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -5
  3. package/dist/advanced.d.ts +1 -0
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/deny-mutations.d.ts +11 -0
  7. package/dist/deny-mutations.d.ts.map +1 -0
  8. package/dist/deny-mutations.js +102 -0
  9. package/dist/device-path.d.ts +13 -0
  10. package/dist/device-path.d.ts.map +1 -0
  11. package/dist/device-path.js +112 -0
  12. package/dist/errors.d.ts +1 -1
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/fsync.d.ts +2 -0
  15. package/dist/fsync.d.ts.map +1 -0
  16. package/dist/fsync.js +21 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/json.d.ts.map +1 -1
  20. package/dist/json.js +51 -4
  21. package/dist/move-path.d.ts +3 -0
  22. package/dist/move-path.d.ts.map +1 -1
  23. package/dist/move-path.js +13 -1
  24. package/dist/opened-realpath.d.ts +3 -0
  25. package/dist/opened-realpath.d.ts.map +1 -0
  26. package/dist/opened-realpath.js +79 -0
  27. package/dist/path.d.ts +1 -0
  28. package/dist/path.d.ts.map +1 -1
  29. package/dist/path.js +1 -0
  30. package/dist/pinned-open.d.ts.map +1 -1
  31. package/dist/pinned-open.js +7 -0
  32. package/dist/pinned-write.d.ts.map +1 -1
  33. package/dist/pinned-write.js +3 -0
  34. package/dist/read-opened-file.d.ts +18 -0
  35. package/dist/read-opened-file.d.ts.map +1 -0
  36. package/dist/read-opened-file.js +15 -0
  37. package/dist/regular-file.d.ts.map +1 -1
  38. package/dist/regular-file.js +26 -3
  39. package/dist/root-impl.d.ts +18 -15
  40. package/dist/root-impl.d.ts.map +1 -1
  41. package/dist/root-impl.js +110 -147
  42. package/dist/root.d.ts +1 -1
  43. package/dist/root.d.ts.map +1 -1
  44. package/dist/secure-file.d.ts.map +1 -1
  45. package/dist/secure-file.js +2 -0
  46. package/dist/walk.d.ts +13 -2
  47. package/dist/walk.d.ts.map +1 -1
  48. package/dist/walk.js +29 -6
  49. package/docs/contributing.md +1 -1
  50. package/docs/errors.md +5 -0
  51. package/docs/install.md +3 -3
  52. package/docs/path.md +13 -0
  53. package/docs/reading.md +6 -4
  54. package/docs/root.md +15 -5
  55. package/docs/security-model.md +7 -2
  56. package/docs/types.md +22 -9
  57. package/docs/walk.md +11 -1
  58. package/docs/writing.md +21 -3
  59. package/package.json +22 -17
package/dist/root-impl.js CHANGED
@@ -6,14 +6,18 @@ import { pipeline } from "node:stream/promises";
6
6
  import { createBoundedReadStream } from "./bounded-read-stream.js";
7
7
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
8
8
  import { FsSafeError } from "./errors.js";
9
+ import { syncDirectoryBestEffort } from "./fsync.js";
9
10
  import { sameFileIdentity } from "./file-identity.js";
10
11
  import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
11
12
  import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
13
+ import { assertMutationNotDenied, mergeDenyMutationPolicies, } from "./deny-mutations.js";
14
+ import { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
12
15
  import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
13
16
  import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
14
17
  import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
15
18
  import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
16
- import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
19
+ import { assertNoNulPathInput, assertNoUnsafeDeviceReadPath, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
20
+ import { readOpenedFileSafely } from "./read-opened-file.js";
17
21
  import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
18
22
  import { pathStatFromStats } from "./path-stat.js";
19
23
  import { resolveRootPath } from "./root-path.js";
@@ -23,6 +27,7 @@ import { getFsSafeTestHooks } from "./test-hooks.js";
23
27
  import { stringifyJsonDocument } from "./json-stringify.js";
24
28
  import { registerTempPathForExit } from "./temp-cleanup.js";
25
29
  import { serializePathWrite } from "./write-queue.js";
30
+ export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
26
31
  function logWarn(message) {
27
32
  if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") {
28
33
  console.warn(message);
@@ -46,20 +51,16 @@ const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
46
51
  fsConstants.O_EXCL |
47
52
  (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
48
53
  export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024;
49
- function closeHandleForDispose(handle) {
50
- return handle.close().catch(() => undefined);
51
- }
52
54
  function openResult(params) {
53
55
  return {
54
56
  handle: params.handle,
55
57
  realPath: params.realPath,
56
58
  stat: params.stat,
57
- [Symbol.asyncDispose]: async () => {
58
- await closeHandleForDispose(params.handle);
59
- },
59
+ [Symbol.asyncDispose]: () => params.handle.close().catch(() => undefined),
60
60
  };
61
61
  }
62
62
  async function openVerifiedLocalFile(filePath, options) {
63
+ assertNoUnsafeDeviceReadPath(filePath);
63
64
  const fsSafeTestHooks = getFsSafeTestHooks();
64
65
  // Reject directories before opening so we never surface EISDIR to callers (e.g. tool
65
66
  // results that get sent to messaging channels). See openclaw/openclaw#31186.
@@ -216,6 +217,7 @@ class RootHandle {
216
217
  mkdir: this.defaults.mkdir,
217
218
  mode: this.defaults.mode,
218
219
  ...options,
220
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
219
221
  append: writeMode === "append",
220
222
  truncateExisting: writeMode === "replace",
221
223
  });
@@ -227,18 +229,29 @@ class RootHandle {
227
229
  mkdir: this.defaults.mkdir,
228
230
  mode: this.defaults.mode,
229
231
  ...options,
232
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
230
233
  });
231
234
  }
232
- async remove(relativePath) {
235
+ async remove(relativePath, options = {}) {
233
236
  assertValidRootRelativePath(relativePath);
234
- await removePathInRoot(this.context, relativePath);
237
+ await removePathInRoot(this.context, {
238
+ relativePath,
239
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
240
+ });
235
241
  }
236
- async mkdir(relativePath) {
242
+ async mkdir(relativePath, options = {}) {
237
243
  assertValidRootRelativePath(relativePath);
238
- await mkdirPathInRoot(this.context, { relativePath });
244
+ await mkdirPathInRoot(this.context, {
245
+ relativePath,
246
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
247
+ });
239
248
  }
240
- async ensureRoot() {
241
- await mkdirPathInRoot(this.context, { relativePath: "", allowRoot: true });
249
+ async ensureRoot(options = {}) {
250
+ await mkdirPathInRoot(this.context, {
251
+ relativePath: "",
252
+ allowRoot: true,
253
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
254
+ });
242
255
  }
243
256
  async write(relativePath, data, options = {}) {
244
257
  await writeFileInRoot(this.context, {
@@ -247,6 +260,7 @@ class RootHandle {
247
260
  mkdir: this.defaults.mkdir,
248
261
  mode: this.defaults.mode,
249
262
  ...options,
263
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
250
264
  });
251
265
  }
252
266
  async create(relativePath, data, options = {}) {
@@ -256,6 +270,7 @@ class RootHandle {
256
270
  mkdir: this.defaults.mkdir,
257
271
  mode: this.defaults.mode,
258
272
  ...options,
273
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
259
274
  overwrite: false,
260
275
  });
261
276
  }
@@ -278,6 +293,7 @@ class RootHandle {
278
293
  mkdir: this.defaults.mkdir,
279
294
  mode: this.defaults.mode,
280
295
  ...options,
296
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
281
297
  });
282
298
  }
283
299
  async exists(relativePath) {
@@ -321,6 +337,12 @@ class RootHandle {
321
337
  async move(fromRelative, toRelative, options = {}) {
322
338
  assertValidRootRelativePath(fromRelative);
323
339
  assertValidRootRelativePath(toRelative);
340
+ const denyMutations = mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations);
341
+ await assertMoveMutationAllowed(this.context, {
342
+ fromRelative,
343
+ toRelative,
344
+ denyMutations,
345
+ });
324
346
  try {
325
347
  await runPinnedHelper("rename", this.rootReal, {
326
348
  from: fromRelative,
@@ -332,6 +354,7 @@ class RootHandle {
332
354
  if (canFallbackFromPythonError(error)) {
333
355
  await movePathFallback(this.context, {
334
356
  fromRelative,
357
+ denyMutations,
335
358
  overwrite: options.overwrite ?? false,
336
359
  toRelative,
337
360
  });
@@ -413,20 +436,6 @@ export async function openLocalFileSafely(params) {
413
436
  assertNoNulPathInput(params.filePath, "file path contains a NUL byte");
414
437
  return await openVerifiedLocalFile(params.filePath);
415
438
  }
416
- async function readOpenedFileSafely(params) {
417
- if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) {
418
- throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
419
- }
420
- const buffer = await params.opened.handle.readFile();
421
- if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
422
- throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`);
423
- }
424
- return {
425
- buffer,
426
- realPath: params.opened.realPath,
427
- stat: params.opened.stat,
428
- };
429
- }
430
439
  function emitWriteBoundaryWarning(reason) {
431
440
  logWarn(`security: fs-safe write boundary warning (${reason})`);
432
441
  }
@@ -467,82 +476,9 @@ async function verifyAtomicWriteResult(params) {
467
476
  await opened.handle.close().catch(() => { });
468
477
  }
469
478
  }
470
- export async function resolveOpenedFileRealPathForHandle(handle, ioPath) {
471
- const handleStat = await handle.stat();
472
- const fdCandidates = process.platform === "linux"
473
- ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
474
- : process.platform === "win32"
475
- ? []
476
- : [`/dev/fd/${handle.fd}`];
477
- for (const fdPath of fdCandidates) {
478
- try {
479
- const fdRealPath = await fs.realpath(fdPath);
480
- const fdRealStat = await fs.stat(fdRealPath);
481
- if (sameFileIdentity(handleStat, fdRealStat)) {
482
- return fdRealPath;
483
- }
484
- }
485
- catch {
486
- // try next fd path
487
- }
488
- }
489
- try {
490
- const ioRealPath = await fs.realpath(ioPath);
491
- const ioRealStat = await fs.stat(ioRealPath);
492
- if (sameFileIdentity(handleStat, ioRealStat)) {
493
- return ioRealPath;
494
- }
495
- }
496
- catch (err) {
497
- if (!isNotFoundPathError(err)) {
498
- throw err;
499
- }
500
- }
501
- const parentResolved = await resolveOpenedFileRealPathFromParent(handleStat, ioPath);
502
- if (parentResolved) {
503
- return parentResolved;
504
- }
505
- throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
506
- }
507
- async function resolveOpenedFileRealPathFromParent(handleStat, ioPath) {
508
- let parentReal;
509
- try {
510
- parentReal = await fs.realpath(path.dirname(ioPath));
511
- }
512
- catch (err) {
513
- if (isNotFoundPathError(err)) {
514
- return null;
515
- }
516
- throw err;
517
- }
518
- let entries;
519
- try {
520
- entries = await fs.readdir(parentReal);
521
- }
522
- catch (err) {
523
- if (isNotFoundPathError(err)) {
524
- return null;
525
- }
526
- throw err;
527
- }
528
- for (const entry of entries.toSorted()) {
529
- const candidatePath = path.join(parentReal, entry);
530
- try {
531
- const candidateStat = await fs.lstat(candidatePath);
532
- if (candidateStat.isFile() && sameFileIdentity(handleStat, candidateStat)) {
533
- return await fs.realpath(candidatePath);
534
- }
535
- }
536
- catch (err) {
537
- if (!isNotFoundPathError(err)) {
538
- throw err;
539
- }
540
- }
541
- }
542
- return null;
543
- }
544
479
  async function openWritableFileInRoot(root, params) {
545
480
  const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
481
+ await assertMutationNotDenied(resolved, params.denyMutations);
546
482
  try {
547
483
  await assertNoPathAliasEscape({
548
484
  absolutePath: resolved,
@@ -649,9 +585,7 @@ async function openWritableFileInRoot(root, params) {
649
585
  createdForWrite,
650
586
  realPath,
651
587
  stat,
652
- [Symbol.asyncDispose]: async () => {
653
- await closeHandleForDispose(handle);
654
- },
588
+ [Symbol.asyncDispose]: () => handle.close().catch(() => undefined),
655
589
  };
656
590
  }
657
591
  catch (err) {
@@ -669,6 +603,7 @@ async function appendFileInRoot(root, params) {
669
603
  relativePath: params.relativePath,
670
604
  mkdir: params.mkdir,
671
605
  mode: params.mode,
606
+ denyMutations: params.denyMutations,
672
607
  truncateExisting: false,
673
608
  append: true,
674
609
  });
@@ -687,17 +622,22 @@ async function appendFileInRoot(root, params) {
687
622
  }
688
623
  if (typeof params.data === "string") {
689
624
  await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8");
690
- return;
691
625
  }
692
- const payload = prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
693
- await target.handle.appendFile(payload);
626
+ else {
627
+ const payload = prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
628
+ await target.handle.appendFile(payload);
629
+ }
630
+ await target.handle.sync();
631
+ if (target.createdForWrite) {
632
+ await syncDirectoryBestEffort(path.dirname(target.realPath));
633
+ }
694
634
  }
695
635
  finally {
696
636
  await target.handle.close().catch(() => { });
697
637
  }
698
638
  }
699
- async function removePathInRoot(root, relativePath) {
700
- const resolved = await resolvePinnedRemovePathInRoot(root, relativePath);
639
+ async function removePathInRoot(root, params) {
640
+ const resolved = await resolvePinnedRemovePathInRoot(root, params.relativePath, params.denyMutations);
701
641
  if (process.platform === "win32") {
702
642
  await removePathFallback(resolved);
703
643
  return;
@@ -745,44 +685,47 @@ async function writeFileInRoot(root, params) {
745
685
  });
746
686
  return;
747
687
  }
748
- const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
688
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
749
689
  await serializePathWrite(pinned.targetPath, async () => {
750
- let identity;
751
- try {
752
- identity = await runPinnedWriteHelper({
753
- rootPath: pinned.rootReal,
754
- relativeParentPath: pinned.relativeParentPath,
755
- basename: pinned.basename,
756
- mkdir: params.mkdir !== false,
757
- mode: params.mode ?? pinned.mode,
758
- overwrite: params.overwrite,
759
- input: {
760
- kind: "buffer",
761
- data: params.data,
762
- encoding: params.encoding,
763
- },
764
- });
765
- }
766
- catch (error) {
767
- if (params.overwrite === false && isAlreadyExistsError(error)) {
768
- throw new FsSafeError("already-exists", "file already exists", {
769
- cause: error instanceof Error ? error : undefined,
770
- });
771
- }
772
- throw normalizePinnedWriteError(error);
773
- }
774
- try {
775
- await verifyAtomicWriteResult({
776
- root,
777
- targetPath: pinned.targetPath,
778
- expectedIdentity: identity,
690
+ await commitPinnedWriteInRoot(root, pinned, params);
691
+ });
692
+ }
693
+ async function commitPinnedWriteInRoot(root, pinned, params) {
694
+ let identity;
695
+ try {
696
+ identity = await runPinnedWriteHelper({
697
+ rootPath: pinned.rootReal,
698
+ relativeParentPath: pinned.relativeParentPath,
699
+ basename: pinned.basename,
700
+ mkdir: params.mkdir !== false,
701
+ mode: params.mode ?? pinned.mode,
702
+ overwrite: params.overwrite,
703
+ input: {
704
+ kind: "buffer",
705
+ data: params.data,
706
+ encoding: params.encoding,
707
+ },
708
+ });
709
+ }
710
+ catch (error) {
711
+ if (params.overwrite === false && isAlreadyExistsError(error)) {
712
+ throw new FsSafeError("already-exists", "file already exists", {
713
+ cause: error instanceof Error ? error : undefined,
779
714
  });
780
715
  }
781
- catch (err) {
782
- emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
783
- throw err;
784
- }
785
- });
716
+ throw normalizePinnedWriteError(error);
717
+ }
718
+ try {
719
+ await verifyAtomicWriteResult({
720
+ root,
721
+ targetPath: pinned.targetPath,
722
+ expectedIdentity: identity,
723
+ });
724
+ }
725
+ catch (err) {
726
+ emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
727
+ throw err;
728
+ }
786
729
  }
787
730
  async function copyFileInRoot(root, params) {
788
731
  assertValidRootRelativePath(params.relativePath);
@@ -801,7 +744,7 @@ async function copyFileInRoot(root, params) {
801
744
  });
802
745
  return;
803
746
  }
804
- const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
747
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
805
748
  await serializePathWrite(pinned.targetPath, async () => {
806
749
  let identity;
807
750
  try {
@@ -845,8 +788,9 @@ async function copyFileInRoot(root, params) {
845
788
  await source.handle.close().catch(() => { });
846
789
  }
847
790
  }
848
- async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode) {
791
+ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode, denyMutations) {
849
792
  const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath);
793
+ await assertMutationNotDenied(resolved, denyMutations);
850
794
  try {
851
795
  await assertNoPathAliasEscape({
852
796
  absolutePath: resolved,
@@ -903,12 +847,16 @@ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode)
903
847
  async function resolvePinnedPathInRoot(root, params) {
904
848
  return await resolvePinnedOperationPathInRoot(root, {
905
849
  allowRoot: params.allowRoot,
850
+ denyMutations: params.denyMutations,
851
+ protectDenyMutationAncestors: false,
906
852
  relativePath: params.relativePath,
907
853
  policy: PATH_ALIAS_POLICIES.strict,
908
854
  });
909
855
  }
910
- async function resolvePinnedRemovePathInRoot(root, relativePath) {
856
+ async function resolvePinnedRemovePathInRoot(root, relativePath, denyMutations) {
911
857
  return await resolvePinnedOperationPathInRoot(root, {
858
+ denyMutations,
859
+ protectDenyMutationAncestors: true,
912
860
  relativePath,
913
861
  policy: PATH_ALIAS_POLICIES.unlinkTarget,
914
862
  });
@@ -920,6 +868,7 @@ async function resolvePinnedOperationPathInRoot(root, params) {
920
868
  });
921
869
  const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
922
870
  if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
871
+ await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations);
923
872
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
924
873
  }
925
874
  const firstSegment = relativeResolved.split(path.sep)[0];
@@ -933,6 +882,9 @@ async function resolvePinnedOperationPathInRoot(root, params) {
933
882
  if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
934
883
  throw new FsSafeError("outside-workspace", "file is outside workspace root");
935
884
  }
885
+ await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations, {
886
+ protectAncestors: params.protectDenyMutationAncestors,
887
+ });
936
888
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
937
889
  }
938
890
  async function resolvePinnedRootPathInRoot(root, params) {
@@ -1010,13 +962,21 @@ async function listPathFallback(root, relativePath, withFileTypes) {
1010
962
  throw error;
1011
963
  }
1012
964
  }
965
+ async function assertMoveMutationAllowed(root, params) {
966
+ const source = await resolvePathInRoot(root, params.fromRelative);
967
+ await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true });
968
+ const target = await resolvePathInRoot(root, params.toRelative);
969
+ await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
970
+ }
1013
971
  async function movePathFallback(root, params) {
1014
972
  const source = await resolvePathInRoot(root, params.fromRelative);
973
+ await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true });
1015
974
  await resolvePinnedRootPathInRoot(root, {
1016
975
  relativePath: params.fromRelative,
1017
976
  policy: PATH_ALIAS_POLICIES.strict,
1018
977
  });
1019
978
  const target = await resolvePathInRoot(root, params.toRelative);
979
+ await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
1020
980
  await resolvePinnedRootPathInRoot(root, {
1021
981
  relativePath: params.toRelative,
1022
982
  policy: PATH_ALIAS_POLICIES.unlinkTarget,
@@ -1100,6 +1060,7 @@ async function writeFileFallback(root, params) {
1100
1060
  relativePath: params.relativePath,
1101
1061
  mkdir: params.mkdir,
1102
1062
  mode: params.mode,
1063
+ denyMutations: params.denyMutations,
1103
1064
  truncateExisting: false,
1104
1065
  });
1105
1066
  const destinationPath = target.realPath;
@@ -1145,6 +1106,7 @@ async function writeFileFallback(root, params) {
1145
1106
  }
1146
1107
  async function writeMissingFileFallback(root, params) {
1147
1108
  const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath);
1109
+ await assertMutationNotDenied(resolved, params.denyMutations);
1148
1110
  try {
1149
1111
  await assertNoPathAliasEscape({
1150
1112
  absolutePath: resolved,
@@ -1218,6 +1180,7 @@ async function copyFileFallback(root, params, source) {
1218
1180
  relativePath: params.relativePath,
1219
1181
  mkdir: params.mkdir,
1220
1182
  mode: params.mode,
1183
+ denyMutations: params.denyMutations,
1221
1184
  truncateExisting: false,
1222
1185
  });
1223
1186
  const destinationPath = target.realPath;
package/dist/root.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { DEFAULT_ROOT_MAX_BYTES, openLocalFileSafely, readLocalFileSafely, resolveOpenedFileRealPathForHandle, root, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root-impl.js";
1
+ export { DEFAULT_ROOT_MAX_BYTES, openLocalFileSafely, readLocalFileSafely, resolveOpenedFileRealPathForHandle, root, type DenyMutationPolicy, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root-impl.js";
2
2
  //# sourceMappingURL=root.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../src/root.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,kCAAkC,EAClC,IAAI,EACJ,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../src/root.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,kCAAkC,EAClC,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAUrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAsKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
1
+ {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAWrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAuKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
@@ -1,6 +1,7 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
4
5
  import { FsSafeError } from "./errors.js";
5
6
  import { sameFileIdentity } from "./file-identity.js";
6
7
  import { isWindowsDriveLetterPath, isWindowsNetworkPath } from "./local-file-access.js";
@@ -17,6 +18,7 @@ function label(options) {
17
18
  return options.label ?? "Secure file";
18
19
  }
19
20
  async function openSecureHandle(options) {
21
+ assertNoUnsafeDeviceReadPath(options.filePath);
20
22
  if (isWindowsNetworkPath(options.filePath, "win32") && !options.trust?.allowNetworkPath) {
21
23
  throw new FsSafeError("invalid-path", `${label(options)} must be a local absolute path.`);
22
24
  }
package/dist/walk.d.ts CHANGED
@@ -16,11 +16,22 @@ export type WalkDirectoryOptions = {
16
16
  include?: (entry: WalkDirectoryEntry) => boolean;
17
17
  descend?: (entry: WalkDirectoryEntry) => boolean;
18
18
  };
19
+ export type WalkDirectoryFailure = {
20
+ path: string;
21
+ relativePath: string;
22
+ depth: number;
23
+ error: unknown;
24
+ };
19
25
  export type WalkDirectoryResult = {
20
26
  entries: WalkDirectoryEntry[];
21
27
  scannedEntryCount: number;
22
28
  truncated: boolean;
29
+ failedDirs?: WalkDirectoryFailure[];
30
+ };
31
+ type WalkDirectoryResultWithFailures = WalkDirectoryResult & {
32
+ failedDirs: WalkDirectoryFailure[];
23
33
  };
24
- export declare function walkDirectorySync(rootDir: string, options?: WalkDirectoryOptions): WalkDirectoryResult;
25
- export declare function walkDirectory(rootDir: string, options?: WalkDirectoryOptions): Promise<WalkDirectoryResult>;
34
+ export declare function walkDirectorySync(rootDir: string, options?: WalkDirectoryOptions): WalkDirectoryResultWithFailures;
35
+ export declare function walkDirectory(rootDir: string, options?: WalkDirectoryOptions): Promise<WalkDirectoryResultWithFailures>;
36
+ export {};
26
37
  //# sourceMappingURL=walk.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"walk.d.ts","sourceRoot":"","sources":["../src/walk.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAI7B,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AACvE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE9D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;IACjD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;CACpB,CAAC;AA8DF,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,mBAAmB,CAiDrB;AAED,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAiD9B"}
1
+ {"version":3,"file":"walk.d.ts","sourceRoot":"","sources":["../src/walk.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAI7B,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AACvE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE9D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;IACjD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;IAGnB,UAAU,CAAC,EAAE,oBAAoB,EAAE,CAAC;CACrC,CAAC;AAEF,KAAK,+BAA+B,GAAG,mBAAmB,GAAG;IAC3D,UAAU,EAAE,oBAAoB,EAAE,CAAC;CACpC,CAAC;AA8EF,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,+BAA+B,CAwDjC;AAED,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,+BAA+B,CAAC,CAwD1C"}
package/dist/walk.js CHANGED
@@ -25,6 +25,15 @@ function buildEntry(params) {
25
25
  dirent: params.dirent,
26
26
  };
27
27
  }
28
+ function recordFailedDir(result, root, dir, depth, error) {
29
+ const relativePath = path.relative(root, dir);
30
+ result.failedDirs.push({
31
+ path: dir,
32
+ relativePath,
33
+ depth: relativePath === "" ? 0 : depth - 1,
34
+ error,
35
+ });
36
+ }
28
37
  function resolveSyncKind(fullPath, dirent, symlinks) {
29
38
  const kind = kindForDirent(dirent);
30
39
  if (kind !== "symlink")
@@ -68,7 +77,12 @@ async function resolveAsyncKind(fullPath, dirent, symlinks) {
68
77
  export function walkDirectorySync(rootDir, options = {}) {
69
78
  const root = path.resolve(rootDir);
70
79
  const symlinks = options.symlinks ?? "skip";
71
- const result = { entries: [], scannedEntryCount: 0, truncated: false };
80
+ const result = {
81
+ entries: [],
82
+ scannedEntryCount: 0,
83
+ truncated: false,
84
+ failedDirs: [],
85
+ };
72
86
  const visitedDirs = new Set();
73
87
  function visit(dir, depth) {
74
88
  if (options.maxDepth !== undefined && depth > options.maxDepth)
@@ -77,7 +91,8 @@ export function walkDirectorySync(rootDir, options = {}) {
77
91
  try {
78
92
  realDir = fsSync.realpathSync(dir);
79
93
  }
80
- catch {
94
+ catch (error) {
95
+ recordFailedDir(result, root, dir, depth, error);
81
96
  return;
82
97
  }
83
98
  if (visitedDirs.has(realDir))
@@ -87,7 +102,8 @@ export function walkDirectorySync(rootDir, options = {}) {
87
102
  try {
88
103
  entries = fsSync.readdirSync(dir, { withFileTypes: true });
89
104
  }
90
- catch {
105
+ catch (error) {
106
+ recordFailedDir(result, root, dir, depth, error);
91
107
  return;
92
108
  }
93
109
  for (const dirent of entries) {
@@ -119,7 +135,12 @@ export function walkDirectorySync(rootDir, options = {}) {
119
135
  export async function walkDirectory(rootDir, options = {}) {
120
136
  const root = path.resolve(rootDir);
121
137
  const symlinks = options.symlinks ?? "skip";
122
- const result = { entries: [], scannedEntryCount: 0, truncated: false };
138
+ const result = {
139
+ entries: [],
140
+ scannedEntryCount: 0,
141
+ truncated: false,
142
+ failedDirs: [],
143
+ };
123
144
  const visitedDirs = new Set();
124
145
  async function visit(dir, depth) {
125
146
  if (options.maxDepth !== undefined && depth > options.maxDepth)
@@ -128,7 +149,8 @@ export async function walkDirectory(rootDir, options = {}) {
128
149
  try {
129
150
  realDir = await fs.realpath(dir);
130
151
  }
131
- catch {
152
+ catch (error) {
153
+ recordFailedDir(result, root, dir, depth, error);
132
154
  return;
133
155
  }
134
156
  if (visitedDirs.has(realDir))
@@ -138,7 +160,8 @@ export async function walkDirectory(rootDir, options = {}) {
138
160
  try {
139
161
  entries = await fs.readdir(dir, { withFileTypes: true });
140
162
  }
141
- catch {
163
+ catch (error) {
164
+ recordFailedDir(result, root, dir, depth, error);
142
165
  return;
143
166
  }
144
167
  for (const dirent of entries) {
@@ -10,7 +10,7 @@ cd fs-safe
10
10
  pnpm install
11
11
  ```
12
12
 
13
- Node 20.11 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
13
+ Node 22 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
14
14
 
15
15
  ## Build
16
16
 
package/docs/errors.md CHANGED
@@ -30,6 +30,8 @@ class FsSafeError extends Error {
30
30
  ```ts
31
31
  type FsSafeErrorCode =
32
32
  | "already-exists"
33
+ | "denied-path"
34
+ | "device-path"
33
35
  | "hardlink"
34
36
  | "helper-failed"
35
37
  | "helper-unavailable"
@@ -55,6 +57,8 @@ type FsSafeErrorCode =
55
57
  | Code | When it fires | Common causes |
56
58
  |---|---|---|
57
59
  | `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. |
60
+ | `denied-path` | A root mutation matched `denyMutations.paths` or `denyMutations.prefixes`. | Caller configured application-sensitive paths that must not be written, removed, moved, or created. |
61
+ | `device-path` | A read/open target is a known unsafe device or process-fd path. | `/dev/zero`, `/dev/random`, `/dev/stdin`, `/dev/fd/*`, `/proc/*/fd/*`, or a Windows reserved device name. |
58
62
  | `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
59
63
  | `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
60
64
  | `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. |
@@ -93,6 +97,7 @@ try {
93
97
  case "not-found":
94
98
  return reply(404, "missing");
95
99
  case "symlink":
100
+ case "device-path":
96
101
  case "hardlink":
97
102
  case "path-mismatch":
98
103
  case "path-alias":