@openclaw/fs-safe 0.2.6 → 0.3.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 (56) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +21 -7
  3. package/dist/archive-deadline.d.ts +9 -0
  4. package/dist/archive-deadline.d.ts.map +1 -0
  5. package/dist/archive-deadline.js +67 -0
  6. package/dist/archive-file-io.d.ts +9 -0
  7. package/dist/archive-file-io.d.ts.map +1 -0
  8. package/dist/archive-file-io.js +11 -0
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +43 -3
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +213 -105
  13. package/dist/deny-mutations.d.ts +11 -0
  14. package/dist/deny-mutations.d.ts.map +1 -0
  15. package/dist/deny-mutations.js +102 -0
  16. package/dist/errors.d.ts +1 -1
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/json-durable-queue.d.ts.map +1 -1
  21. package/dist/json-durable-queue.js +5 -0
  22. package/dist/json.d.ts.map +1 -1
  23. package/dist/json.js +51 -4
  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/output.d.ts.map +1 -1
  28. package/dist/output.js +1 -0
  29. package/dist/pinned-write.js +3 -9
  30. package/dist/read-opened-file.d.ts +18 -0
  31. package/dist/read-opened-file.d.ts.map +1 -0
  32. package/dist/read-opened-file.js +15 -0
  33. package/dist/regular-file.d.ts.map +1 -1
  34. package/dist/regular-file.js +23 -3
  35. package/dist/root-impl.d.ts +18 -15
  36. package/dist/root-impl.d.ts.map +1 -1
  37. package/dist/root-impl.js +60 -100
  38. package/dist/root-paths.d.ts.map +1 -1
  39. package/dist/root-paths.js +16 -5
  40. package/dist/root.d.ts +1 -1
  41. package/dist/root.d.ts.map +1 -1
  42. package/dist/secret-file.d.ts +1 -0
  43. package/dist/secret-file.d.ts.map +1 -1
  44. package/dist/secret-file.js +23 -3
  45. package/dist/sidecar-lock.d.ts.map +1 -1
  46. package/dist/sidecar-lock.js +9 -3
  47. package/dist/symlink-parents.js +2 -2
  48. package/docs/errors.md +2 -0
  49. package/docs/python-helper.md +3 -3
  50. package/docs/root.md +19 -9
  51. package/docs/secret-file.md +3 -2
  52. package/docs/security-model.md +4 -0
  53. package/docs/timing.md +1 -1
  54. package/docs/types.md +21 -9
  55. package/docs/writing.md +19 -1
  56. package/package.json +1 -1
package/dist/root-impl.js CHANGED
@@ -9,11 +9,14 @@ import { FsSafeError } from "./errors.js";
9
9
  import { sameFileIdentity } from "./file-identity.js";
10
10
  import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
11
11
  import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
12
+ import { assertMutationNotDenied, mergeDenyMutationPolicies, } from "./deny-mutations.js";
13
+ import { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
12
14
  import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
13
15
  import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
14
16
  import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
15
17
  import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
16
18
  import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
19
+ import { readOpenedFileSafely } from "./read-opened-file.js";
17
20
  import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
18
21
  import { pathStatFromStats } from "./path-stat.js";
19
22
  import { resolveRootPath } from "./root-path.js";
@@ -23,6 +26,7 @@ import { getFsSafeTestHooks } from "./test-hooks.js";
23
26
  import { stringifyJsonDocument } from "./json-stringify.js";
24
27
  import { registerTempPathForExit } from "./temp-cleanup.js";
25
28
  import { serializePathWrite } from "./write-queue.js";
29
+ export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
26
30
  function logWarn(message) {
27
31
  if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") {
28
32
  console.warn(message);
@@ -216,6 +220,7 @@ class RootHandle {
216
220
  mkdir: this.defaults.mkdir,
217
221
  mode: this.defaults.mode,
218
222
  ...options,
223
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
219
224
  append: writeMode === "append",
220
225
  truncateExisting: writeMode === "replace",
221
226
  });
@@ -227,18 +232,29 @@ class RootHandle {
227
232
  mkdir: this.defaults.mkdir,
228
233
  mode: this.defaults.mode,
229
234
  ...options,
235
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
230
236
  });
231
237
  }
232
- async remove(relativePath) {
238
+ async remove(relativePath, options = {}) {
233
239
  assertValidRootRelativePath(relativePath);
234
- await removePathInRoot(this.context, relativePath);
240
+ await removePathInRoot(this.context, {
241
+ relativePath,
242
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
243
+ });
235
244
  }
236
- async mkdir(relativePath) {
245
+ async mkdir(relativePath, options = {}) {
237
246
  assertValidRootRelativePath(relativePath);
238
- await mkdirPathInRoot(this.context, { relativePath });
247
+ await mkdirPathInRoot(this.context, {
248
+ relativePath,
249
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
250
+ });
239
251
  }
240
- async ensureRoot() {
241
- await mkdirPathInRoot(this.context, { relativePath: "", allowRoot: true });
252
+ async ensureRoot(options = {}) {
253
+ await mkdirPathInRoot(this.context, {
254
+ relativePath: "",
255
+ allowRoot: true,
256
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
257
+ });
242
258
  }
243
259
  async write(relativePath, data, options = {}) {
244
260
  await writeFileInRoot(this.context, {
@@ -247,6 +263,7 @@ class RootHandle {
247
263
  mkdir: this.defaults.mkdir,
248
264
  mode: this.defaults.mode,
249
265
  ...options,
266
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
250
267
  });
251
268
  }
252
269
  async create(relativePath, data, options = {}) {
@@ -256,6 +273,7 @@ class RootHandle {
256
273
  mkdir: this.defaults.mkdir,
257
274
  mode: this.defaults.mode,
258
275
  ...options,
276
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
259
277
  overwrite: false,
260
278
  });
261
279
  }
@@ -278,6 +296,7 @@ class RootHandle {
278
296
  mkdir: this.defaults.mkdir,
279
297
  mode: this.defaults.mode,
280
298
  ...options,
299
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
281
300
  });
282
301
  }
283
302
  async exists(relativePath) {
@@ -321,6 +340,12 @@ class RootHandle {
321
340
  async move(fromRelative, toRelative, options = {}) {
322
341
  assertValidRootRelativePath(fromRelative);
323
342
  assertValidRootRelativePath(toRelative);
343
+ const denyMutations = mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations);
344
+ await assertMoveMutationAllowed(this.context, {
345
+ fromRelative,
346
+ toRelative,
347
+ denyMutations,
348
+ });
324
349
  try {
325
350
  await runPinnedHelper("rename", this.rootReal, {
326
351
  from: fromRelative,
@@ -332,6 +357,7 @@ class RootHandle {
332
357
  if (canFallbackFromPythonError(error)) {
333
358
  await movePathFallback(this.context, {
334
359
  fromRelative,
360
+ denyMutations,
335
361
  overwrite: options.overwrite ?? false,
336
362
  toRelative,
337
363
  });
@@ -413,20 +439,6 @@ export async function openLocalFileSafely(params) {
413
439
  assertNoNulPathInput(params.filePath, "file path contains a NUL byte");
414
440
  return await openVerifiedLocalFile(params.filePath);
415
441
  }
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
442
  function emitWriteBoundaryWarning(reason) {
431
443
  logWarn(`security: fs-safe write boundary warning (${reason})`);
432
444
  }
@@ -467,82 +479,9 @@ async function verifyAtomicWriteResult(params) {
467
479
  await opened.handle.close().catch(() => { });
468
480
  }
469
481
  }
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
482
  async function openWritableFileInRoot(root, params) {
545
483
  const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
484
+ await assertMutationNotDenied(resolved, params.denyMutations);
546
485
  try {
547
486
  await assertNoPathAliasEscape({
548
487
  absolutePath: resolved,
@@ -669,6 +608,7 @@ async function appendFileInRoot(root, params) {
669
608
  relativePath: params.relativePath,
670
609
  mkdir: params.mkdir,
671
610
  mode: params.mode,
611
+ denyMutations: params.denyMutations,
672
612
  truncateExisting: false,
673
613
  append: true,
674
614
  });
@@ -696,8 +636,8 @@ async function appendFileInRoot(root, params) {
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,7 +685,7 @@ 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
690
  let identity;
751
691
  try {
@@ -801,7 +741,7 @@ async function copyFileInRoot(root, params) {
801
741
  });
802
742
  return;
803
743
  }
804
- const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
744
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
805
745
  await serializePathWrite(pinned.targetPath, async () => {
806
746
  let identity;
807
747
  try {
@@ -845,8 +785,9 @@ async function copyFileInRoot(root, params) {
845
785
  await source.handle.close().catch(() => { });
846
786
  }
847
787
  }
848
- async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode) {
788
+ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode, denyMutations) {
849
789
  const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath);
790
+ await assertMutationNotDenied(resolved, denyMutations);
850
791
  try {
851
792
  await assertNoPathAliasEscape({
852
793
  absolutePath: resolved,
@@ -903,12 +844,16 @@ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode)
903
844
  async function resolvePinnedPathInRoot(root, params) {
904
845
  return await resolvePinnedOperationPathInRoot(root, {
905
846
  allowRoot: params.allowRoot,
847
+ denyMutations: params.denyMutations,
848
+ protectDenyMutationAncestors: false,
906
849
  relativePath: params.relativePath,
907
850
  policy: PATH_ALIAS_POLICIES.strict,
908
851
  });
909
852
  }
910
- async function resolvePinnedRemovePathInRoot(root, relativePath) {
853
+ async function resolvePinnedRemovePathInRoot(root, relativePath, denyMutations) {
911
854
  return await resolvePinnedOperationPathInRoot(root, {
855
+ denyMutations,
856
+ protectDenyMutationAncestors: true,
912
857
  relativePath,
913
858
  policy: PATH_ALIAS_POLICIES.unlinkTarget,
914
859
  });
@@ -920,6 +865,7 @@ async function resolvePinnedOperationPathInRoot(root, params) {
920
865
  });
921
866
  const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
922
867
  if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
868
+ await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations);
923
869
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
924
870
  }
925
871
  const firstSegment = relativeResolved.split(path.sep)[0];
@@ -933,6 +879,9 @@ async function resolvePinnedOperationPathInRoot(root, params) {
933
879
  if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
934
880
  throw new FsSafeError("outside-workspace", "file is outside workspace root");
935
881
  }
882
+ await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations, {
883
+ protectAncestors: params.protectDenyMutationAncestors,
884
+ });
936
885
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
937
886
  }
938
887
  async function resolvePinnedRootPathInRoot(root, params) {
@@ -1010,13 +959,21 @@ async function listPathFallback(root, relativePath, withFileTypes) {
1010
959
  throw error;
1011
960
  }
1012
961
  }
962
+ async function assertMoveMutationAllowed(root, params) {
963
+ const source = await resolvePathInRoot(root, params.fromRelative);
964
+ await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true });
965
+ const target = await resolvePathInRoot(root, params.toRelative);
966
+ await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
967
+ }
1013
968
  async function movePathFallback(root, params) {
1014
969
  const source = await resolvePathInRoot(root, params.fromRelative);
970
+ await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true });
1015
971
  await resolvePinnedRootPathInRoot(root, {
1016
972
  relativePath: params.fromRelative,
1017
973
  policy: PATH_ALIAS_POLICIES.strict,
1018
974
  });
1019
975
  const target = await resolvePathInRoot(root, params.toRelative);
976
+ await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
1020
977
  await resolvePinnedRootPathInRoot(root, {
1021
978
  relativePath: params.toRelative,
1022
979
  policy: PATH_ALIAS_POLICIES.unlinkTarget,
@@ -1100,6 +1057,7 @@ async function writeFileFallback(root, params) {
1100
1057
  relativePath: params.relativePath,
1101
1058
  mkdir: params.mkdir,
1102
1059
  mode: params.mode,
1060
+ denyMutations: params.denyMutations,
1103
1061
  truncateExisting: false,
1104
1062
  });
1105
1063
  const destinationPath = target.realPath;
@@ -1145,6 +1103,7 @@ async function writeFileFallback(root, params) {
1145
1103
  }
1146
1104
  async function writeMissingFileFallback(root, params) {
1147
1105
  const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath);
1106
+ await assertMutationNotDenied(resolved, params.denyMutations);
1148
1107
  try {
1149
1108
  await assertNoPathAliasEscape({
1150
1109
  absolutePath: resolved,
@@ -1218,6 +1177,7 @@ async function copyFileFallback(root, params, source) {
1218
1177
  relativePath: params.relativePath,
1219
1178
  mkdir: params.mkdir,
1220
1179
  mode: params.mode,
1180
+ denyMutations: params.denyMutations,
1221
1181
  truncateExisting: false,
1222
1182
  });
1223
1183
  const destinationPath = target.realPath;
@@ -1 +1 @@
1
- {"version":3,"file":"root-paths.d.ts","sourceRoot":"","sources":["../src/root-paths.ts"],"names":[],"mappings":"AAMA,KAAK,iBAAiB,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AACtD,KAAK,4BAA4B,GAAG;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AACF,KAAK,4BAA4B,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,iBAAiB,CAAC;AACtF,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AACF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AACF,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CACL,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAChC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,UAAU,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,4BAA4B,CAAC;IACnE,QAAQ,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,4BAA4B,CAAC,CAAC;IAC1E,KAAK,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,4BAA4B,CAAC,CAAC;IACvE,QAAQ,CACN,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtE,SAAS,CACP,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACpD,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvE,CAAC;AAuDF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAe5D;AAED,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAiCrE;AAoDD,wBAAsB,yBAAyB,CAAC,MAAM,EAAE;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CA8DrE;AAED,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,4BAA4B,GACnC,4BAA4B,CAc9B;AAED,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,4BAA4B,GACnC,OAAO,CAAC,4BAA4B,CAAC,CAEvC;AAED,wBAAsB,oCAAoC,CACxD,MAAM,EAAE,4BAA4B,GACnC,OAAO,CAAC,4BAA4B,CAAC,CAEvC;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG,SAAS,CAwC/E"}
1
+ {"version":3,"file":"root-paths.d.ts","sourceRoot":"","sources":["../src/root-paths.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { FsSafeError } from "./errors.js";
4
- import { isNotFoundPathError, isPathInside } from "./path.js";
4
+ import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
5
5
  import { root as openRoot } from "./root.js";
6
6
  function invalidPath(scopeLabel) {
7
7
  return {
@@ -9,6 +9,10 @@ function invalidPath(scopeLabel) {
9
9
  error: `Invalid path: must stay within ${scopeLabel}`,
10
10
  };
11
11
  }
12
+ function pathStaysWithinRoot(rootDir, candidatePath) {
13
+ const relative = path.relative(rootDir, candidatePath);
14
+ return Boolean(relative) && !isPathRelativeEscape(relative);
15
+ }
12
16
  async function resolveRealPathIfExists(targetPath) {
13
17
  try {
14
18
  return await fs.realpath(targetPath);
@@ -58,11 +62,14 @@ export function resolvePathWithinRoot(params) {
58
62
  if (!params.defaultFileName) {
59
63
  return { ok: false, error: "path is required" };
60
64
  }
61
- return { ok: true, path: path.join(root, params.defaultFileName) };
65
+ const defaultPath = path.resolve(root, params.defaultFileName);
66
+ if (!pathStaysWithinRoot(root, defaultPath)) {
67
+ return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
68
+ }
69
+ return { ok: true, path: defaultPath };
62
70
  }
63
71
  const resolved = path.resolve(root, raw);
64
- const rel = path.relative(root, resolved);
65
- if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
72
+ if (!pathStaysWithinRoot(root, resolved)) {
66
73
  return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
67
74
  }
68
75
  return { ok: true, path: resolved };
@@ -118,7 +125,7 @@ async function resolveNearestExistingPath(targetPath) {
118
125
  }
119
126
  async function assertNoSymlinkSegments(params) {
120
127
  const relative = path.relative(params.rootDir, params.targetPath);
121
- if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
128
+ if (isPathRelativeEscape(relative)) {
122
129
  throw new Error(`Invalid path: must stay within ${params.scopeLabel}`);
123
130
  }
124
131
  let current = params.rootDir;
@@ -195,6 +202,10 @@ export async function ensureDirectoryWithinRoot(params) {
195
202
  }
196
203
  }
197
204
  }
205
+ const currentReal = await fs.realpath(current);
206
+ if (!isPathInside(rootReal, currentReal)) {
207
+ return invalidPath(params.scopeLabel);
208
+ }
198
209
  }
199
210
  const targetReal = await fs.realpath(targetPath);
200
211
  if (!isPathInside(rootReal, targetReal)) {
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"}
@@ -4,6 +4,7 @@ export declare const PRIVATE_SECRET_FILE_MODE = 384;
4
4
  export type SecretFileReadOptions = {
5
5
  maxBytes?: number;
6
6
  rejectSymlink?: boolean;
7
+ rejectHardlinks?: boolean;
7
8
  };
8
9
  export declare function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;
9
10
  export declare function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAqHF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAMpB;AAoID,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+ChB"}
1
+ {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAkIF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAcpB;AAoID,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+ChB"}
@@ -13,6 +13,10 @@ export const PRIVATE_SECRET_FILE_MODE = 0o600;
13
13
  function normalizeSecretReadError(error) {
14
14
  return error instanceof Error ? error : new Error(String(error));
15
15
  }
16
+ function secretPathErrorCode(error) {
17
+ const code = error.code;
18
+ return code === "ENOENT" || code === "ENOTDIR" ? "not-found" : "invalid-path";
19
+ }
16
20
  function resolveUserPath(input) {
17
21
  return resolveHomeRelativePath(input);
18
22
  }
@@ -31,7 +35,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
31
35
  const normalized = normalizeSecretReadError(error);
32
36
  return {
33
37
  ok: false,
34
- code: error.code === "ENOENT" ? "not-found" : "invalid-path",
38
+ code: secretPathErrorCode(error),
35
39
  error: normalized,
36
40
  message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
37
41
  };
@@ -45,7 +49,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
45
49
  const normalized = normalizeSecretReadError(error);
46
50
  return {
47
51
  ok: false,
48
- code: error.code === "ENOENT" ? "not-found" : "invalid-path",
52
+ code: secretPathErrorCode(error),
49
53
  error: normalized,
50
54
  message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
51
55
  };
@@ -66,6 +70,13 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
66
70
  message: `${label} file at ${resolvedPath} must be a regular file.`,
67
71
  };
68
72
  }
73
+ if (options.rejectHardlinks !== false && previewStat.nlink > 1) {
74
+ return {
75
+ ok: false,
76
+ code: "hardlink",
77
+ message: `${label} file at ${resolvedPath} must not be hardlinked.`,
78
+ };
79
+ }
69
80
  if (previewStat.size > maxBytes) {
70
81
  return {
71
82
  ok: false,
@@ -76,6 +87,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
76
87
  const opened = openPinnedFileSync({
77
88
  filePath: resolvedPath,
78
89
  rejectPathSymlink: options.rejectSymlink,
90
+ rejectHardlinks: options.rejectHardlinks !== false,
79
91
  maxBytes,
80
92
  });
81
93
  if (!opened.ok) {
@@ -126,7 +138,15 @@ export function tryReadSecretFileSync(filePath, label, options = {}) {
126
138
  return undefined;
127
139
  }
128
140
  const result = readSecretFileOutcomeSync(filePath, label, options);
129
- return result.ok ? result.secret : undefined;
141
+ if (result.ok) {
142
+ return result.secret;
143
+ }
144
+ if (result.code === "not-found") {
145
+ return undefined;
146
+ }
147
+ throw new FsSafeError(result.code, result.message, {
148
+ cause: result.error,
149
+ });
130
150
  }
131
151
  function isRelativeEscape(relativePath) {
132
152
  return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,aAAa,GAAG,qBAAqB,CAAC;AAE7E,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,yBAAyB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,uBAAuB,CAAC;IAChC,aAAa,CAAC,EAAE,wBAAwB,CAAC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,EAAE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,oBAAoB,EAAE,MAAM,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;QACxC,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,iBAAiB,EAAE,OAAO,CAAC;KAC5B,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjC,qBAAqB,CAAC,EAAE,CACtB,QAAQ,EAAE,wBAAwB,KAC/B,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,YAAY,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,sBAAsB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,IAAI,CACjF,yBAAyB,CAAC,QAAQ,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AA2OF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAW3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAiIL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBASW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
@@ -34,8 +34,11 @@ async function readLockSnapshot(lockPath) {
34
34
  return { raw, payload: null, stat };
35
35
  }
36
36
  }
37
- catch {
38
- return null;
37
+ catch (err) {
38
+ if (err.code === "ENOENT") {
39
+ return null;
40
+ }
41
+ throw err;
39
42
  }
40
43
  }
41
44
  function snapshotMatches(current, observed) {
@@ -86,7 +89,10 @@ async function removeStaleLockIfAllowed(params) {
86
89
  try {
87
90
  await fs.rm(params.lockPath, { force: true });
88
91
  }
89
- catch {
92
+ catch (err) {
93
+ if (err.code === "ENOENT") {
94
+ return "changed";
95
+ }
90
96
  return "not-approved";
91
97
  }
92
98
  return "removed";
@@ -1,12 +1,12 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { isNotFoundPathError } from "./path.js";
4
+ import { isNotFoundPathError, isPathRelativeEscape } from "./path.js";
5
5
  function resolvePathWalk(params) {
6
6
  const root = path.resolve(params.rootDir);
7
7
  const target = path.resolve(params.targetPath);
8
8
  const relative = path.relative(root, target);
9
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
9
+ if (isPathRelativeEscape(relative)) {
10
10
  if (params.allowOutsideRoot) {
11
11
  return null;
12
12
  }
package/docs/errors.md CHANGED
@@ -30,6 +30,7 @@ class FsSafeError extends Error {
30
30
  ```ts
31
31
  type FsSafeErrorCode =
32
32
  | "already-exists"
33
+ | "denied-path"
33
34
  | "hardlink"
34
35
  | "helper-failed"
35
36
  | "helper-unavailable"
@@ -55,6 +56,7 @@ type FsSafeErrorCode =
55
56
  | Code | When it fires | Common causes |
56
57
  |---|---|---|
57
58
  | `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. |
59
+ | `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. |
58
60
  | `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
59
61
  | `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
60
62
  | `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. |
@@ -62,12 +62,12 @@ Node-only mode still keeps the important application-level guardrails:
62
62
  - root-relative path validation;
63
63
  - canonical root checks;
64
64
  - no-follow opens where Node/platform support exists;
65
- - file identity checks around reads and writes where a safe Node fallback exists;
66
- - atomic sibling-temp replacement on fallback platforms;
65
+ - file identity checks around reads and writes;
66
+ - atomic sibling-temp replacement;
67
67
  - hardlink/symlink policy checks where the API requests them;
68
68
  - byte limits and structured `FsSafeError` failures.
69
69
 
70
- What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation. Write paths that need fd-relative parent commits now fail closed when the helper is disabled or unavailable; other operations such as `root().move()`, `root().remove()`, and `root().mkdir()` may still rely on Node path operations plus pre/post checks instead of parent-fd syscalls.
70
+ What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation. Without fd-relative mutation, `root().move()`, `root().remove()`, `root().mkdir()`, and some write paths rely on Node path operations plus pre/post checks instead of parent-fd syscalls.
71
71
 
72
72
  That is usually acceptable when the root directory is only writable by the trusted application user. It is not the right posture if untrusted local processes can race writes in the same tree and you are relying on `fs-safe` as part of the security boundary.
73
73