@openclaw/fs-safe 0.2.4 → 0.2.6

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 (104) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +8 -9
  3. package/dist/archive-staging.d.ts.map +1 -1
  4. package/dist/archive-staging.js +12 -1
  5. package/dist/file-store-boundary.js +2 -2
  6. package/dist/file-store.d.ts.map +1 -1
  7. package/dist/file-store.js +6 -5
  8. package/dist/guarded-mkdir.d.ts.map +1 -1
  9. package/dist/guarded-mkdir.js +6 -6
  10. package/dist/json-document-store.d.ts +1 -1
  11. package/dist/json-document-store.d.ts.map +1 -1
  12. package/dist/json-document-store.js +5 -3
  13. package/dist/json-durable-queue.d.ts.map +1 -1
  14. package/dist/json-durable-queue.js +157 -4
  15. package/dist/json-stringify.d.ts +2 -0
  16. package/dist/json-stringify.d.ts.map +1 -0
  17. package/dist/json-stringify.js +7 -0
  18. package/dist/json.d.ts.map +1 -1
  19. package/dist/json.js +3 -2
  20. package/dist/local-roots.d.ts.map +1 -1
  21. package/dist/local-roots.js +4 -1
  22. package/dist/path.d.ts +1 -0
  23. package/dist/path.d.ts.map +1 -1
  24. package/dist/path.js +4 -1
  25. package/dist/permissions.d.ts.map +1 -1
  26. package/dist/permissions.js +7 -10
  27. package/dist/pinned-python.d.ts.map +1 -1
  28. package/dist/pinned-python.js +158 -69
  29. package/dist/pinned-write.d.ts +3 -1
  30. package/dist/pinned-write.d.ts.map +1 -1
  31. package/dist/pinned-write.js +54 -45
  32. package/dist/root-impl.d.ts.map +1 -1
  33. package/dist/root-impl.js +6 -2
  34. package/dist/root-path.js +2 -2
  35. package/dist/root-paths.js +25 -3
  36. package/dist/secret-file.d.ts.map +1 -1
  37. package/dist/secret-file.js +76 -50
  38. package/dist/secure-file.d.ts.map +1 -1
  39. package/dist/secure-file.js +3 -1
  40. package/dist/temp-target.js +1 -1
  41. package/dist/timing.d.ts.map +1 -1
  42. package/dist/timing.js +8 -1
  43. package/docs/index.md +1 -1
  44. package/docs/json-store.md +3 -1
  45. package/docs/python-helper.md +3 -3
  46. package/docs/root.md +5 -6
  47. package/package.json +4 -4
  48. package/dist/archive-utils.d.ts +0 -3
  49. package/dist/archive-utils.d.ts.map +0 -1
  50. package/dist/archive-utils.js +0 -26
  51. package/dist/boundary-file-read.d.ts +0 -44
  52. package/dist/boundary-file-read.d.ts.map +0 -1
  53. package/dist/boundary-file-read.js +0 -129
  54. package/dist/boundary-path.d.ts +0 -39
  55. package/dist/boundary-path.d.ts.map +0 -1
  56. package/dist/boundary-path.js +0 -598
  57. package/dist/fs-pinned-path-helper.d.ts +0 -7
  58. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  59. package/dist/fs-pinned-path-helper.js +0 -182
  60. package/dist/fs-pinned-write-helper.d.ts +0 -21
  61. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  62. package/dist/fs-pinned-write-helper.js +0 -263
  63. package/dist/hardlink-guards.d.ts +0 -7
  64. package/dist/hardlink-guards.d.ts.map +0 -1
  65. package/dist/hardlink-guards.js +0 -30
  66. package/dist/install-safe-path.d.ts +0 -20
  67. package/dist/install-safe-path.d.ts.map +0 -1
  68. package/dist/install-safe-path.js +0 -94
  69. package/dist/json-file.d.ts +0 -3
  70. package/dist/json-file.d.ts.map +0 -1
  71. package/dist/json-file.js +0 -123
  72. package/dist/json-files.d.ts +0 -20
  73. package/dist/json-files.d.ts.map +0 -1
  74. package/dist/json-files.js +0 -153
  75. package/dist/path-alias-guards.d.ts +0 -19
  76. package/dist/path-alias-guards.d.ts.map +0 -1
  77. package/dist/path-alias-guards.js +0 -21
  78. package/dist/path-guards.d.ts +0 -7
  79. package/dist/path-guards.d.ts.map +0 -1
  80. package/dist/path-guards.js +0 -49
  81. package/dist/path-safety.d.ts +0 -12
  82. package/dist/path-safety.d.ts.map +0 -1
  83. package/dist/path-safety.js +0 -50
  84. package/dist/path-scope.d.ts +0 -2
  85. package/dist/path-scope.d.ts.map +0 -1
  86. package/dist/path-scope.js +0 -1
  87. package/dist/private-file-store.d.ts +0 -60
  88. package/dist/private-file-store.d.ts.map +0 -1
  89. package/dist/private-file-store.js +0 -233
  90. package/dist/safe-open-sync.d.ts +0 -24
  91. package/dist/safe-open-sync.d.ts.map +0 -1
  92. package/dist/safe-open-sync.js +0 -71
  93. package/dist/safe-root.d.ts +0 -123
  94. package/dist/safe-root.d.ts.map +0 -1
  95. package/dist/safe-root.js +0 -1060
  96. package/dist/secure-temp-workspace.d.ts +0 -25
  97. package/dist/secure-temp-workspace.d.ts.map +0 -1
  98. package/dist/secure-temp-workspace.js +0 -136
  99. package/dist/sibling-temp-file.d.ts +0 -16
  100. package/dist/sibling-temp-file.d.ts.map +0 -1
  101. package/dist/sibling-temp-file.js +0 -73
  102. package/dist/sibling-temp-write.d.ts +0 -8
  103. package/dist/sibling-temp-write.d.ts.map +0 -1
  104. package/dist/sibling-temp-write.js +0 -40
@@ -2,11 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import fsSync from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { Transform } from "node:stream";
6
- import { pipeline } from "node:stream/promises";
7
- import { createNearestExistingDirectoryGuard } from "./directory-guard.js";
5
+ import { createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
8
6
  import { FsSafeError } from "./errors.js";
7
+ import { sameFileIdentity } from "./file-identity.js";
9
8
  import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
9
+ import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
10
10
  import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
11
11
  import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
12
12
  function byteLength(input, encoding) {
@@ -28,29 +28,21 @@ function assertWithinMaxBytes(bytes, maxBytes) {
28
28
  throw new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`);
29
29
  }
30
30
  }
31
- function createMaxBytesTransform(maxBytes) {
32
- if (maxBytes === undefined) {
33
- return undefined;
34
- }
31
+ async function writeStreamToHandle(stream, handle, maxBytes) {
35
32
  let bytes = 0;
36
- return new Transform({
37
- transform(chunk, _encoding, callback) {
38
- bytes += chunk.byteLength;
39
- if (bytes > maxBytes) {
40
- callback(new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`));
41
- return;
33
+ for await (const chunk of stream) {
34
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
35
+ bytes += buffer.byteLength;
36
+ assertWithinMaxBytes(bytes, maxBytes);
37
+ let offset = 0;
38
+ while (offset < buffer.byteLength) {
39
+ const { bytesWritten } = await handle.write(buffer, offset, buffer.byteLength - offset);
40
+ if (bytesWritten <= 0) {
41
+ throw new FsSafeError("helper-failed", "fallback stream write made no progress");
42
42
  }
43
- callback(null, chunk);
44
- },
45
- });
46
- }
47
- async function pipelineWithMaxBytes(stream, destination, maxBytes) {
48
- const limiter = createMaxBytesTransform(maxBytes);
49
- if (limiter) {
50
- await pipeline(stream, limiter, destination);
51
- return;
43
+ offset += bytesWritten;
44
+ }
52
45
  }
53
- await pipeline(stream, destination);
54
46
  }
55
47
  async function inputToBase64(input, maxBytes) {
56
48
  if (input.kind === "buffer") {
@@ -75,7 +67,7 @@ export async function runPinnedWriteHelper(params) {
75
67
  relativeParentPath: params.relativeParentPath,
76
68
  });
77
69
  if (getFsSafePythonConfig().mode === "off") {
78
- return await runPinnedWriteFallback(params);
70
+ return await runPinnedWriteFallbackOrThrow(params);
79
71
  }
80
72
  if (params.input.kind === "stream") {
81
73
  try {
@@ -83,7 +75,7 @@ export async function runPinnedWriteHelper(params) {
83
75
  }
84
76
  catch (error) {
85
77
  if (canFallbackFromPythonError(error)) {
86
- return await runPinnedWriteFallback(params);
78
+ return await runPinnedWriteFallbackOrThrow(params, error);
87
79
  }
88
80
  throw error;
89
81
  }
@@ -96,6 +88,7 @@ export async function runPinnedWriteHelper(params) {
96
88
  mode: params.mode || 0o600,
97
89
  overwrite: params.overwrite !== false,
98
90
  relativeParentPath: params.relativeParentPath,
91
+ ...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
99
92
  };
100
93
  try {
101
94
  return await runPinnedPythonOperation({
@@ -106,7 +99,7 @@ export async function runPinnedWriteHelper(params) {
106
99
  }
107
100
  catch (error) {
108
101
  if (canFallbackFromPythonError(error)) {
109
- return await runPinnedWriteFallback(params);
102
+ return await runPinnedWriteFallbackOrThrow(params, error);
110
103
  }
111
104
  throw error;
112
105
  }
@@ -126,22 +119,29 @@ export async function runPinnedCopyHelper(params) {
126
119
  mode: params.mode || 0o600,
127
120
  overwrite: params.overwrite !== false,
128
121
  relativeParentPath: params.relativeParentPath,
122
+ ...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
129
123
  sourceDev: params.sourceIdentity.dev,
130
124
  sourceIno: params.sourceIdentity.ino,
131
125
  sourcePath: params.sourcePath,
132
126
  },
133
127
  });
134
128
  }
129
+ async function runPinnedWriteFallbackOrThrow(params, cause) {
130
+ if (process.platform !== "win32") {
131
+ throw new FsSafeError("helper-unavailable", "Python helper is required for pinned writes on this platform", { cause });
132
+ }
133
+ return await runPinnedWriteFallback(params);
134
+ }
135
135
  async function runPinnedWriteFallback(params) {
136
136
  const parentPath = params.relativeParentPath
137
137
  ? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
138
138
  : params.rootPath;
139
- const parentGuard = await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
140
139
  if (params.mkdir) {
141
- await withAsyncDirectoryGuards([parentGuard], async () => {
142
- await fs.mkdir(parentPath, { recursive: true });
143
- });
140
+ await mkdirPathComponentsWithGuards({ rootReal: params.rootPath, targetPath: parentPath });
144
141
  }
142
+ const parentGuard = params.mkdir
143
+ ? await createAsyncDirectoryGuard(parentPath)
144
+ : await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
145
145
  const targetPath = path.join(parentPath, params.basename);
146
146
  if (params.overwrite === false) {
147
147
  let handle = await withAsyncDirectoryGuards([parentGuard], async () => await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode), {
@@ -163,7 +163,7 @@ async function runPinnedWriteFallback(params) {
163
163
  }
164
164
  }
165
165
  else {
166
- await pipelineWithMaxBytes(params.input.stream, handle.createWriteStream(), params.maxBytes);
166
+ await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
167
167
  }
168
168
  const stat = await handle.stat();
169
169
  created = false;
@@ -184,7 +184,9 @@ async function runPinnedWriteFallback(params) {
184
184
  ? fsSync.constants.O_NOFOLLOW
185
185
  : 0);
186
186
  let handle;
187
- let handleClosedByStream = false;
187
+ let tempStat;
188
+ let targetStat;
189
+ let renamed = false;
188
190
  try {
189
191
  handle = await fs.open(tempPath, tempFlags, params.mode);
190
192
  if (params.input.kind === "buffer") {
@@ -197,27 +199,34 @@ async function runPinnedWriteFallback(params) {
197
199
  }
198
200
  }
199
201
  else {
200
- const writable = handle.createWriteStream();
201
- writable.once("close", () => {
202
- handleClosedByStream = true;
203
- });
204
- await pipelineWithMaxBytes(params.input.stream, writable, params.maxBytes);
202
+ await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
205
203
  }
206
- if (!handleClosedByStream) {
207
- await handle.close().catch(() => undefined);
208
- handle = undefined;
204
+ tempStat = await handle.stat();
205
+ const tempPathStat = await fs.lstat(tempPath);
206
+ if (tempPathStat.isSymbolicLink() || !sameFileIdentity(tempPathStat, tempStat)) {
207
+ throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
209
208
  }
209
+ const expectedTempStat = tempStat;
210
+ await handle.close().catch(() => undefined);
211
+ handle = undefined;
210
212
  await withAsyncDirectoryGuards([parentGuard], async () => {
211
213
  await fs.rename(tempPath, targetPath);
214
+ renamed = true;
215
+ targetStat = await fs.lstat(targetPath);
216
+ if (targetStat.isSymbolicLink() || !sameFileIdentity(targetStat, expectedTempStat)) {
217
+ throw new FsSafeError("path-mismatch", "fallback target changed during write");
218
+ }
212
219
  });
213
220
  }
214
221
  catch (error) {
215
- if (handle && !handleClosedByStream) {
216
- await handle.close().catch(() => undefined);
222
+ await handle?.close().catch(() => undefined);
223
+ if (!renamed) {
224
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
217
225
  }
218
- await fs.rm(tempPath, { force: true }).catch(() => undefined);
219
226
  throw error;
220
227
  }
221
- const stat = await fs.stat(targetPath);
222
- return { dev: stat.dev, ino: stat.ino };
228
+ if (!targetStat) {
229
+ throw new FsSafeError("path-mismatch", "fallback target was not verified");
230
+ }
231
+ return { dev: targetStat.dev, ino: targetStat.ino };
223
232
  }
@@ -1 +1 @@
1
- {"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
package/dist/root-impl.js CHANGED
@@ -20,6 +20,7 @@ import { resolveRootPath } from "./root-path.js";
20
20
  import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
21
21
  import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
22
22
  import { getFsSafeTestHooks } from "./test-hooks.js";
23
+ import { stringifyJsonDocument } from "./json-stringify.js";
23
24
  import { registerTempPathForExit } from "./temp-cleanup.js";
24
25
  import { serializePathWrite } from "./write-queue.js";
25
26
  function logWarn(message) {
@@ -260,12 +261,12 @@ class RootHandle {
260
261
  }
261
262
  async writeJson(relativePath, data, options = {}) {
262
263
  const { replacer, space, trailingNewline = true, ...writeOptions } = options;
263
- const json = JSON.stringify(data, replacer, space);
264
+ const json = stringifyJsonDocument(data, replacer, space);
264
265
  await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
265
266
  }
266
267
  async createJson(relativePath, data, options = {}) {
267
268
  const { replacer, space, trailingNewline = true, ...writeOptions } = options;
268
- const json = JSON.stringify(data, replacer, space);
269
+ const json = stringifyJsonDocument(data, replacer, space);
269
270
  await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
270
271
  }
271
272
  async copyIn(relativePath, sourcePath, options = {}) {
@@ -1050,6 +1051,9 @@ async function movePathFallback(root, params) {
1050
1051
  if (sourceStat.isFile() && sourceStat.nlink > 1) {
1051
1052
  throw new FsSafeError("hardlink", "hardlinked path not allowed");
1052
1053
  }
1054
+ if (!params.overwrite && sourceStat.isDirectory()) {
1055
+ throw new FsSafeError("invalid-path", "directory moves require overwrite: true");
1056
+ }
1053
1057
  if (!params.overwrite) {
1054
1058
  try {
1055
1059
  await fs.lstat(target.resolved);
package/dist/root-path.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { isNotFoundPathError, isPathInside } from "./path.js";
5
+ import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
6
6
  export const ROOT_PATH_ALIAS_POLICIES = {
7
7
  strict: Object.freeze({
8
8
  allowFinalSymlinkForUnlink: false,
@@ -531,7 +531,7 @@ function relativeInsideRoot(rootPath, targetPath) {
531
531
  if (!relative || relative === ".") {
532
532
  return "";
533
533
  }
534
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
534
+ if (isPathRelativeEscape(relative)) {
535
535
  return "";
536
536
  }
537
537
  return relative;
@@ -62,7 +62,7 @@ export function resolvePathWithinRoot(params) {
62
62
  }
63
63
  const resolved = path.resolve(root, raw);
64
64
  const rel = path.relative(root, resolved);
65
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
65
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
66
66
  return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
67
67
  }
68
68
  return { ok: true, path: resolved };
@@ -118,7 +118,7 @@ async function resolveNearestExistingPath(targetPath) {
118
118
  }
119
119
  async function assertNoSymlinkSegments(params) {
120
120
  const relative = path.relative(params.rootDir, params.targetPath);
121
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
121
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
122
122
  throw new Error(`Invalid path: must stay within ${params.scopeLabel}`);
123
123
  }
124
124
  let current = params.rootDir;
@@ -266,7 +266,10 @@ async function resolveCheckedPathsWithinRoot(params, allowMissingFallback) {
266
266
  const rootDir = path.resolve(params.rootDir);
267
267
  const rootRealPath = await resolveRealPathIfExists(rootDir);
268
268
  const root = rootRealPath ? await openRoot(rootDir) : undefined;
269
- const isInRoot = (relativePath) => Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
269
+ const isInRoot = (relativePath) => Boolean(relativePath) &&
270
+ relativePath !== ".." &&
271
+ !relativePath.startsWith(`..${path.sep}`) &&
272
+ !path.isAbsolute(relativePath);
270
273
  const resolveExistingRelativePath = async (requestedPath) => {
271
274
  const raw = requestedPath.trim();
272
275
  const lexicalPathResult = resolvePathWithinRoot({
@@ -316,6 +319,25 @@ async function resolveCheckedPathsWithinRoot(params, allowMissingFallback) {
316
319
  }
317
320
  catch (err) {
318
321
  if (allowMissingFallback && err instanceof FsSafeError && err.code === "not-found") {
322
+ if (!rootRealPath) {
323
+ resolvedPaths.push(pathResult.fallbackPath);
324
+ continue;
325
+ }
326
+ try {
327
+ await assertNoSymlinkSegments({
328
+ rootDir,
329
+ targetPath: pathResult.fallbackPath,
330
+ scopeLabel: params.scopeLabel,
331
+ });
332
+ const existingPath = await resolveNearestExistingPath(pathResult.fallbackPath);
333
+ const existingRealPath = await fs.realpath(existingPath);
334
+ if (!isPathInside(rootRealPath, existingRealPath)) {
335
+ return invalidPath(params.scopeLabel);
336
+ }
337
+ }
338
+ catch {
339
+ return invalidPath(params.scopeLabel);
340
+ }
319
341
  resolvedPaths.push(pathResult.fallbackPath);
320
342
  continue;
321
343
  }
@@ -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;AAoFD,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,CAyDhB"}
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,12 +1,12 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import fs from "node:fs";
3
2
  import fsp from "node:fs/promises";
4
3
  import path from "node:path";
5
- import { createAsyncDirectoryGuard } from "./directory-guard.js";
4
+ import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
6
5
  import { FsSafeError } from "./errors.js";
7
- import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
6
+ import { sameFileIdentity } from "./file-identity.js";
8
7
  import { resolveHomeRelativePath } from "./home-dir.js";
9
8
  import { openPinnedFileSync } from "./pinned-open.js";
9
+ import { runPinnedWriteHelper } from "./pinned-write.js";
10
10
  export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
11
11
  export const PRIVATE_SECRET_DIR_MODE = 0o700;
12
12
  export const PRIVATE_SECRET_FILE_MODE = 0o600;
@@ -128,15 +128,18 @@ export function tryReadSecretFileSync(filePath, label, options = {}) {
128
128
  const result = readSecretFileOutcomeSync(filePath, label, options);
129
129
  return result.ok ? result.secret : undefined;
130
130
  }
131
+ function isRelativeEscape(relativePath) {
132
+ return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
133
+ }
131
134
  function assertPathWithinRoot(rootDir, targetPath) {
132
135
  const relative = path.relative(rootDir, targetPath);
133
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
136
+ if (!relative || isRelativeEscape(relative)) {
134
137
  throw new Error(`Private secret path must stay under ${rootDir}.`);
135
138
  }
136
139
  }
137
140
  function assertRealPathWithinRoot(rootDir, targetPath) {
138
141
  const relative = path.relative(rootDir, targetPath);
139
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
142
+ if (isRelativeEscape(relative)) {
140
143
  throw new Error(`Private secret path must stay under ${rootDir}.`);
141
144
  }
142
145
  }
@@ -151,30 +154,62 @@ async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {
151
154
  throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
152
155
  }
153
156
  }
157
+ async function enforcePrivateFileIdentityAndMode(resolvedPath, expectedIdentity, expectedMode) {
158
+ const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
159
+ const handle = await fsp.open(resolvedPath, fs.constants.O_RDONLY | noFollowFlag);
160
+ try {
161
+ const openedStat = await handle.stat();
162
+ if (!openedStat.isFile() || !sameFileIdentity(openedStat, expectedIdentity)) {
163
+ throw new FsSafeError("path-mismatch", "private secret file changed during write");
164
+ }
165
+ const pathStat = await fsp.lstat(resolvedPath);
166
+ if (pathStat.isSymbolicLink() || !sameFileIdentity(pathStat, openedStat)) {
167
+ throw new FsSafeError("path-mismatch", "private secret path changed during write");
168
+ }
169
+ if (process.platform !== "win32") {
170
+ await handle.chmod(expectedMode);
171
+ const chmodStat = await handle.stat();
172
+ const actualMode = chmodStat.mode & 0o777;
173
+ if (actualMode !== expectedMode) {
174
+ throw new Error(`Private secret file ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
175
+ }
176
+ const refreshedPathStat = await fsp.lstat(resolvedPath);
177
+ if (refreshedPathStat.isSymbolicLink() || !sameFileIdentity(refreshedPathStat, chmodStat)) {
178
+ throw new FsSafeError("path-mismatch", "private secret path changed during mode check");
179
+ }
180
+ }
181
+ }
182
+ finally {
183
+ await handle.close().catch(() => undefined);
184
+ }
185
+ }
154
186
  async function ensurePrivateDirectory(rootDir, targetDir, mode) {
155
187
  const resolvedRoot = path.resolve(rootDir);
156
188
  const resolvedTarget = path.resolve(targetDir);
189
+ await fsp.mkdir(resolvedRoot, { recursive: true, mode });
190
+ const rootStat = await fsp.lstat(resolvedRoot);
191
+ if (rootStat.isSymbolicLink()) {
192
+ throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
193
+ }
194
+ if (!rootStat.isDirectory()) {
195
+ throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
196
+ }
197
+ const rootGuard = await createAsyncDirectoryGuard(resolvedRoot);
198
+ await enforcePrivatePathMode(rootGuard.realPath, mode, "directory");
199
+ await assertAsyncDirectoryGuard(rootGuard);
157
200
  if (resolvedTarget === resolvedRoot) {
158
- await fsp.mkdir(resolvedRoot, { recursive: true, mode });
159
- const rootStat = await fsp.lstat(resolvedRoot);
160
- if (rootStat.isSymbolicLink()) {
161
- throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
162
- }
163
- if (!rootStat.isDirectory()) {
164
- throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
165
- }
166
- await enforcePrivatePathMode(resolvedRoot, mode, "directory");
167
- return;
201
+ return { rootGuard, targetReal: rootGuard.realPath };
168
202
  }
169
203
  assertPathWithinRoot(resolvedRoot, resolvedTarget);
170
- await ensurePrivateDirectory(resolvedRoot, resolvedRoot, mode);
171
- const resolvedRootReal = await fsp.realpath(resolvedRoot);
204
+ const resolvedRootReal = rootGuard.realPath;
172
205
  let current = resolvedRoot;
173
206
  for (const segment of path
174
207
  .relative(resolvedRoot, resolvedTarget)
175
208
  .split(path.sep)
176
209
  .filter(Boolean)) {
177
210
  current = path.join(current, segment);
211
+ const parentGuard = await createAsyncDirectoryGuard(path.dirname(current));
212
+ await assertAsyncDirectoryGuard(rootGuard);
178
213
  try {
179
214
  const stat = await fsp.lstat(current);
180
215
  if (stat.isSymbolicLink()) {
@@ -188,12 +223,16 @@ async function ensurePrivateDirectory(rootDir, targetDir, mode) {
188
223
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
189
224
  throw error;
190
225
  }
226
+ await assertAsyncDirectoryGuard(parentGuard);
191
227
  await fsp.mkdir(current, { mode });
192
228
  }
193
229
  const currentReal = await fsp.realpath(current);
194
230
  assertRealPathWithinRoot(resolvedRootReal, currentReal);
195
231
  await enforcePrivatePathMode(currentReal, mode, "directory");
232
+ await assertAsyncDirectoryGuard(parentGuard);
233
+ await assertAsyncDirectoryGuard(rootGuard);
196
234
  }
235
+ return { rootGuard, targetReal: await fsp.realpath(resolvedTarget) };
197
236
  }
198
237
  export async function writeSecretFileAtomic(params) {
199
238
  const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;
@@ -202,13 +241,12 @@ export async function writeSecretFileAtomic(params) {
202
241
  const resolvedFile = path.resolve(params.filePath);
203
242
  assertPathWithinRoot(resolvedRoot, resolvedFile);
204
243
  const intendedParentDir = path.dirname(resolvedFile);
205
- await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
206
- const resolvedRootReal = await fsp.realpath(resolvedRoot);
207
- const parentDir = await fsp.realpath(intendedParentDir);
208
- assertRealPathWithinRoot(resolvedRootReal, parentDir);
209
- const parentGuard = await createAsyncDirectoryGuard(parentDir);
244
+ const { rootGuard, targetReal } = await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
245
+ await assertAsyncDirectoryGuard(rootGuard);
246
+ assertRealPathWithinRoot(rootGuard.realPath, targetReal);
247
+ const parentGuard = await createAsyncDirectoryGuard(targetReal);
210
248
  const fileName = path.basename(resolvedFile);
211
- const finalFilePath = path.join(parentDir, fileName);
249
+ const finalFilePath = path.join(targetReal, fileName);
212
250
  try {
213
251
  const stat = await fsp.lstat(finalFilePath);
214
252
  if (stat.isSymbolicLink()) {
@@ -223,31 +261,19 @@ export async function writeSecretFileAtomic(params) {
223
261
  throw error;
224
262
  }
225
263
  }
226
- const tempPath = path.join(parentDir, `.tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}`);
227
- let createdTemp = false;
228
- try {
229
- const handle = await fsp.open(tempPath, "wx", mode);
230
- createdTemp = true;
231
- try {
232
- await handle.writeFile(params.content);
233
- }
234
- finally {
235
- await handle.close();
236
- }
237
- await enforcePrivatePathMode(tempPath, mode, "file");
238
- const refreshedParentReal = await fsp.realpath(intendedParentDir);
239
- if (refreshedParentReal !== parentDir) {
240
- throw new Error(`Private secret parent directory changed during write for ${finalFilePath}.`);
241
- }
242
- await withAsyncDirectoryGuards([parentGuard], async () => {
243
- await fsp.rename(tempPath, finalFilePath);
244
- });
245
- createdTemp = false;
246
- await enforcePrivatePathMode(finalFilePath, mode, "file");
247
- }
248
- finally {
249
- if (createdTemp) {
250
- await fsp.unlink(tempPath).catch(() => undefined);
251
- }
252
- }
264
+ await assertAsyncDirectoryGuard(rootGuard);
265
+ await assertAsyncDirectoryGuard(parentGuard);
266
+ const identity = await runPinnedWriteHelper({
267
+ rootPath: parentGuard.realPath,
268
+ relativeParentPath: "",
269
+ basename: fileName,
270
+ mkdir: false,
271
+ mode,
272
+ overwrite: true,
273
+ input: { kind: "buffer", data: typeof params.content === "string" ? params.content : Buffer.from(params.content) },
274
+ rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
275
+ });
276
+ await assertAsyncDirectoryGuard(rootGuard);
277
+ await assertAsyncDirectoryGuard(parentGuard);
278
+ await enforcePrivateFileIdentityAndMode(finalFilePath, identity, mode);
253
279
  }
@@ -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;AAkKF,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;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"}
@@ -9,7 +9,9 @@ import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadab
9
9
  const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
10
10
  const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
11
11
  function isAbsolutePathname(value) {
12
- return path.isAbsolute(value) || isWindowsDriveLetterPath(value, "win32") || isWindowsNetworkPath(value, "win32");
12
+ return (path.isAbsolute(value) ||
13
+ (process.platform === "win32" &&
14
+ (isWindowsDriveLetterPath(value, "win32") || isWindowsNetworkPath(value, "win32"))));
13
15
  }
14
16
  function label(options) {
15
17
  return options.label ?? "Secure file";
@@ -52,7 +52,7 @@ async function cleanupTempDir(dir, onCleanupError) {
52
52
  }
53
53
  }
54
54
  function resolveTempRoot(rootDir) {
55
- return rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" });
55
+ return path.resolve(rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" }));
56
56
  }
57
57
  export async function tempFile(params) {
58
58
  const rootDir = resolveTempRoot(params.rootDir);
@@ -1 +1 @@
1
- {"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA,wBAAsB,WAAW,CAAC,CAAC,EACjC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,cAAc,GAAE,MAAM,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,KAAK,CAAA;CAErF,GACA,OAAO,CAAC,CAAC,CAAC,CAuBZ"}
1
+ {"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA,wBAAsB,WAAW,CAAC,CAAC,EACjC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,cAAc,GAAE,MAAM,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,KAAK,CAAA;CAErF,GACA,OAAO,CAAC,CAAC,CAAC,CA6BZ"}
package/dist/timing.js CHANGED
@@ -12,7 +12,14 @@ export async function withTimeout(promise, timeoutMs, labelOrOptions = {
12
12
  return await Promise.race([
13
13
  promise,
14
14
  new Promise((_, reject) => {
15
- timeoutId = setTimeout(() => reject(createError()), timeoutMs);
15
+ timeoutId = setTimeout(() => {
16
+ try {
17
+ reject(createError());
18
+ }
19
+ catch (error) {
20
+ reject(error);
21
+ }
22
+ }, timeoutMs);
16
23
  }),
17
24
  ]);
18
25
  }
package/docs/index.md CHANGED
@@ -64,7 +64,7 @@ await fs.remove("notes/archive/today.txt");
64
64
  | [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
65
65
  | [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
66
66
  | [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
67
- | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
67
+ | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry, fail-closed stale-lock handling, and opt-in caller-approved stale removal. |
68
68
  | [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
69
69
  | [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
70
70
  | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
@@ -53,7 +53,7 @@ type JsonStoreLockOptions = {
53
53
  staleMs?: number; // default 30_000
54
54
  timeoutMs?: number; // default 30_000
55
55
  retry?: FileLockRetryOptions;
56
- staleRecovery?: "fail-closed";
56
+ staleRecovery?: "fail-closed" | "remove-if-unchanged";
57
57
  managerKey?: string; // default `fs-safe.json-store:<filePath>`
58
58
  };
59
59
 
@@ -141,6 +141,8 @@ When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` sha
141
141
 
142
142
  Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
143
143
 
144
+ JSON store locks do not expose `shouldRemoveStaleLock`, so `staleRecovery: "remove-if-unchanged"` cannot remove a stale sidecar by itself. Use the lower-level [file lock](sidecar-lock.md) API when your application needs custom owner-liveness checks and caller-approved stale-lock removal.
145
+
144
146
  The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
145
147
 
146
148
  ## Common patterns
@@ -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;
66
- - atomic sibling-temp replacement;
65
+ - file identity checks around reads and writes where a safe Node fallback exists;
66
+ - atomic sibling-temp replacement on fallback platforms;
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. 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.
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.
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