@openclaw/fs-safe 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7 - 2026-05-20
4
+
5
+ ### Security and Correctness
6
+
7
+ - Restore best-effort Node write fallbacks when the Python helper is disabled or unavailable, preserving the `mode: "off"` contract while documenting the weaker POSIX same-UID race resistance compared with fd-relative helper commits.
8
+ - Harden DeepSec-reported archive staging and input pinning, secret and queue hardlink rejection, absolute output file validation, sidecar lock read failures, ClawSweeper dispatch trust checks, and scoped path defaults.
9
+
3
10
  ## 0.2.6 - 2026-05-17
4
11
 
5
12
  ### Security and Correctness
package/README.md CHANGED
@@ -66,15 +66,18 @@ before first use when you need a strict environment policy:
66
66
  import { configureFsSafePython } from "@openclaw/fs-safe";
67
67
 
68
68
  configureFsSafePython({ mode: "auto" }); // default: use helper, fall back if unavailable
69
- configureFsSafePython({ mode: "off" }); // never spawn Python; writes that need it fail closed
69
+ configureFsSafePython({ mode: "off" }); // never spawn Python; use best-effort Node fallbacks
70
70
  configureFsSafePython({ mode: "require" }); // fail closed if helper cannot start
71
71
  ```
72
72
 
73
73
  Equivalent env vars: `FS_SAFE_PYTHON_MODE=auto|off|require` and
74
- `FS_SAFE_PYTHON=/path/to/python3`. On POSIX, disabling or losing the helper now
75
- fails closed for root write paths that require fd-relative parent commits.
76
- Windows already uses the Node fallback path. See the
77
- [Python helper policy](docs/python-helper.md) for deployment guidance.
74
+ `FS_SAFE_PYTHON=/path/to/python3`. Without Python, `fs-safe` keeps lexical and
75
+ canonical root checks, no-follow opens, atomic temp+rename writes, and
76
+ post-write identity verification. What you lose is the strongest POSIX
77
+ fd-relative protection against a same-process-user racer swapping parent
78
+ directories between validation and mutation. Windows already uses the Node
79
+ fallback path. See the [Python helper policy](docs/python-helper.md) for
80
+ deployment guidance.
78
81
 
79
82
  ## Quick start
80
83
 
@@ -432,7 +435,7 @@ Current `FsSafeErrorCode` values are `already-exists`, `hardlink`, `helper-faile
432
435
  - root-bounded APIs resolve paths against a configured root and reject canonical escapes
433
436
  - reads open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
434
437
  - writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
435
- - `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX; security-sensitive writes fail closed when that helper is disabled or unavailable
438
+ - `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX, with Node fallbacks when the helper is disabled or unavailable
436
439
  - archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
437
440
 
438
441
  ## Limitations
@@ -0,0 +1,9 @@
1
+ export type ExtractionDeadline = {
2
+ signal: AbortSignal;
3
+ check: () => void;
4
+ dispose: () => void;
5
+ };
6
+ export declare function createPipelineTimeoutError(err: unknown, deadline: ExtractionDeadline): unknown;
7
+ export declare function waitForDeadline<T>(promise: Promise<T>, deadline: ExtractionDeadline): Promise<T>;
8
+ export declare function withExtractionDeadline<T>(timeoutMs: number, label: string, run: (deadline: ExtractionDeadline) => Promise<T>): Promise<T>;
9
+ //# sourceMappingURL=archive-deadline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive-deadline.d.ts","sourceRoot":"","sources":["../src/archive-deadline.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAWF,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,kBAAkB,GAC3B,OAAO,CAST;AAED,wBAAsB,eAAe,CAAC,CAAC,EACrC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,QAAQ,EAAE,kBAAkB,GAC3B,OAAO,CAAC,CAAC,CAAC,CAgBZ;AA4BD,wBAAsB,sBAAsB,CAAC,CAAC,EAC5C,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,CAAC,CAAC,CAAC,GAChD,OAAO,CAAC,CAAC,CAAC,CAQZ"}
@@ -0,0 +1,67 @@
1
+ function signalReason(signal, fallback) {
2
+ const reason = signal.reason;
3
+ return reason instanceof Error ? reason : fallback ?? new Error(String(reason));
4
+ }
5
+ function deadlineReason(deadline) {
6
+ return signalReason(deadline.signal);
7
+ }
8
+ export function createPipelineTimeoutError(err, deadline) {
9
+ if (deadline.signal.aborted &&
10
+ err instanceof Error &&
11
+ (err.name === "AbortError" || err.message === "The operation was aborted")) {
12
+ return deadlineReason(deadline);
13
+ }
14
+ return err;
15
+ }
16
+ export async function waitForDeadline(promise, deadline) {
17
+ deadline.check();
18
+ if (deadline.signal.aborted) {
19
+ throw deadlineReason(deadline);
20
+ }
21
+ return await Promise.race([
22
+ promise,
23
+ new Promise((_, reject) => {
24
+ const abort = () => reject(deadlineReason(deadline));
25
+ deadline.signal.addEventListener("abort", abort, { once: true });
26
+ const cleanup = () => {
27
+ deadline.signal.removeEventListener("abort", abort);
28
+ };
29
+ promise.then(cleanup, cleanup);
30
+ }),
31
+ ]);
32
+ }
33
+ function createExtractionDeadline(timeoutMs, label) {
34
+ const controller = new AbortController();
35
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
36
+ return {
37
+ signal: controller.signal,
38
+ check: () => undefined,
39
+ dispose: () => undefined,
40
+ };
41
+ }
42
+ const timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`);
43
+ const timeoutId = setTimeout(() => {
44
+ controller.abort(timeoutError);
45
+ }, timeoutMs);
46
+ return {
47
+ signal: controller.signal,
48
+ check: () => {
49
+ if (controller.signal.aborted) {
50
+ throw signalReason(controller.signal, timeoutError);
51
+ }
52
+ },
53
+ dispose: () => {
54
+ clearTimeout(timeoutId);
55
+ },
56
+ };
57
+ }
58
+ export async function withExtractionDeadline(timeoutMs, label, run) {
59
+ const deadline = createExtractionDeadline(timeoutMs, label);
60
+ try {
61
+ deadline.check();
62
+ return await waitForDeadline(run(deadline), deadline);
63
+ }
64
+ finally {
65
+ deadline.dispose();
66
+ }
67
+ }
@@ -0,0 +1,9 @@
1
+ import type { FileHandle } from "node:fs/promises";
2
+ import type { ExtractionDeadline } from "./archive-deadline.js";
3
+ export declare function writeFileHandleFully(params: {
4
+ handle: FileHandle;
5
+ buffer: Buffer;
6
+ bytes: number;
7
+ deadline: ExtractionDeadline;
8
+ }): Promise<void>;
9
+ //# sourceMappingURL=archive-file-io.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive-file-io.d.ts","sourceRoot":"","sources":["../src/archive-file-io.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,kBAAkB,CAAC;CAC9B,GAAG,OAAO,CAAC,IAAI,CAAC,CAchB"}
@@ -0,0 +1,11 @@
1
+ export async function writeFileHandleFully(params) {
2
+ let offset = 0;
3
+ while (offset < params.bytes) {
4
+ params.deadline.check();
5
+ const { bytesWritten } = await params.handle.write(params.buffer, offset, params.bytes - offset);
6
+ if (bytesWritten <= 0) {
7
+ throw new Error("archive staging write made no progress");
8
+ }
9
+ offset += bytesWritten;
10
+ }
11
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,CAAC;AAEpC,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,IAAI,EAAE,wBAAwB,CAAC;gBAEnB,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAKpF;AAkCD,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BnF;AA6CD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmChB;AAiGD,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,MAAM,EAAE;IAC5D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;CACzC,GAAG,OAAO,CAAC,CAAC,CAAC,CAkBb;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;CAC5B,GAAG,OAAO,CAAC,IAAI,CAAC,CAyEhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
1
+ {"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,CAAC;AAEpC,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,IAAI,EAAE,wBAAwB,CAAC;gBAEnB,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAKpF;AAkCD,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BnF;AA6CD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmChB;AAyHD,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,MAAM,EAAE;IAC5D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;CACzC,GAAG,OAAO,CAAC,CAAC,CAAC,CA6Bb;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;CAC5B,GAAG,OAAO,CAAC,IAAI,CAAC,CAgFhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
@@ -195,6 +195,16 @@ async function assertExtractedFileHasNoHardlinkAlias(params) {
195
195
  }
196
196
  async function removeExtractedDestinationFile(params) {
197
197
  const destinationPath = path.join(params.destinationRealDir, params.relPath);
198
+ let stat;
199
+ try {
200
+ stat = await fs.lstat(destinationPath);
201
+ }
202
+ catch {
203
+ return;
204
+ }
205
+ if (!stat.isFile()) {
206
+ return;
207
+ }
198
208
  let resolved;
199
209
  try {
200
210
  resolved = await fs.realpath(destinationPath);
@@ -205,7 +215,19 @@ async function removeExtractedDestinationFile(params) {
205
215
  if (!isPathInside(params.destinationRealDir, resolved)) {
206
216
  return;
207
217
  }
208
- await fs.rm(destinationPath, { force: true }).catch(() => undefined);
218
+ const targetRoot = await root(params.destinationRealDir);
219
+ await targetRoot.remove(params.relPath).catch(() => undefined);
220
+ }
221
+ function assertSafeArchiveStagingPrefix(prefix) {
222
+ if (!prefix ||
223
+ prefix === "." ||
224
+ prefix === ".." ||
225
+ prefix.includes("/") ||
226
+ prefix.includes("\\") ||
227
+ path.basename(prefix) !== prefix) {
228
+ throw new Error("archive staging prefix must be a single path segment");
229
+ }
230
+ return prefix;
209
231
  }
210
232
  export async function withStagedArchiveDestination(params) {
211
233
  const stagingRoot = resolveSecureTempRoot({
@@ -216,20 +238,34 @@ export async function withStagedArchiveDestination(params) {
216
238
  if (isPathInside(params.destinationRealDir, stagingRoot)) {
217
239
  throw new Error(`archive staging root must be outside destination: ${stagingRoot}`);
218
240
  }
219
- const stagingDir = await fs.mkdtemp(path.join(stagingRoot, params.stagingDirPrefix ?? "fs-safe-archive-"));
241
+ const stagingPrefix = assertSafeArchiveStagingPrefix(params.stagingDirPrefix ?? "fs-safe-archive-");
242
+ const stagingDir = await fs.mkdtemp(path.join(stagingRoot, stagingPrefix));
243
+ const stagingGuard = await createDirectoryIdentityGuard(stagingDir);
220
244
  try {
221
245
  await fs.chmod(stagingDir, ARCHIVE_STAGING_MODE).catch(() => undefined);
246
+ await assertDirectoryIdentityGuard(stagingGuard);
222
247
  return await params.run(stagingDir);
223
248
  }
224
249
  finally {
225
- await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
250
+ try {
251
+ await assertDirectoryIdentityGuard(stagingGuard);
252
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
253
+ }
254
+ catch {
255
+ // The staging path identity changed; deleting by name could target data
256
+ // outside the private temp tree, so fail closed and leave it for OS cleanup.
257
+ }
226
258
  }
227
259
  }
228
260
  export async function mergeExtractedTreeIntoDestination(params) {
229
261
  const targetRoot = await root(params.destinationRealDir);
262
+ const sourceRootGuard = await createDirectoryIdentityGuard(params.sourceDir);
263
+ const sourceRootReal = sourceRootGuard.realPath;
230
264
  const walk = async (currentSourceDir) => {
265
+ await assertDirectoryIdentityGuard(sourceRootGuard);
231
266
  const entries = await fs.readdir(currentSourceDir, { withFileTypes: true });
232
267
  for (const entry of entries) {
268
+ await assertDirectoryIdentityGuard(sourceRootGuard);
233
269
  const sourcePath = path.join(currentSourceDir, entry.name);
234
270
  const relPath = path.relative(params.sourceDir, sourcePath);
235
271
  const originalPath = relPath.split(path.sep).join("/");
@@ -238,6 +274,10 @@ export async function mergeExtractedTreeIntoDestination(params) {
238
274
  if (sourceStat.isSymbolicLink()) {
239
275
  throw symlinkTraversalError(originalPath);
240
276
  }
277
+ const sourceReal = await fs.realpath(sourcePath);
278
+ if (!isPathInside(sourceRootReal, sourceReal)) {
279
+ throw symlinkTraversalError(originalPath);
280
+ }
241
281
  if (sourceStat.isDirectory()) {
242
282
  await prepareArchiveOutputPath({
243
283
  destinationDir: params.destinationDir,
@@ -1 +1 @@
1
- {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAgBzE,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC,CAAC;AAEF,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,6BAA6B,EAC7B,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,EACvB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,oBAAoB,EAAE,KAAK,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAC3F,OAAO,EACL,kCAAkC,EAClC,iCAAiC,EACjC,4BAA4B,EAC5B,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,8BAA8B,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrF,OAAO,EACL,2BAA2B,EAC3B,iCAAiC,EACjC,KAAK,mBAAmB,GACzB,MAAM,4BAA4B,CAAC;AA8MpC,wBAAsB,cAAc,CAAC,MAAM,EAAE;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyEhB"}
1
+ {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAkBA,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAiBzE,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC,CAAC;AAEF,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,6BAA6B,EAC7B,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,EACvB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,oBAAoB,EAAE,KAAK,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAC3F,OAAO,EACL,kCAAkC,EAClC,iCAAiC,EACjC,4BAA4B,EAC5B,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,8BAA8B,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrF,OAAO,EACL,2BAA2B,EAC3B,iCAAiC,EACjC,KAAK,mBAAmB,GACzB,MAAM,4BAA4B,CAAC;AAuTpC,wBAAsB,cAAc,CAAC,MAAM,EAAE;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6EhB"}
package/dist/archive.js CHANGED
@@ -4,13 +4,16 @@ import path from "node:path";
4
4
  import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, } from "./archive-entry.js";
7
+ import { createPipelineTimeoutError, waitForDeadline, withExtractionDeadline, } from "./archive-deadline.js";
8
+ import { writeFileHandleFully } from "./archive-file-io.js";
7
9
  import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, assertArchiveEntryCountWithinLimit, createByteBudgetTracker, createExtractBudgetTransform, resolveExtractLimits, } from "./archive-limits.js";
8
10
  import { resolveArchiveKind } from "./archive-kind.js";
9
11
  import { mergeExtractedTreeIntoDestination, prepareArchiveDestinationDir, prepareArchiveOutputPath, withStagedArchiveDestination, } from "./archive-staging.js";
10
12
  import { createTarEntryPreflightChecker, readTarEntryInfo, } from "./archive-tar.js";
11
13
  import { loadZipArchiveWithPreflight } from "./archive-zip-preflight.js";
14
+ import { sameFileIdentity } from "./file-identity.js";
12
15
  import { writeSiblingTempFile } from "./sibling-temp.js";
13
- import { withTimeout } from "./timing.js";
16
+ import { tempFile } from "./temp-target.js";
14
17
  export { isWindowsDrivePath, normalizeArchiveEntryPath, resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, } from "./archive-entry.js";
15
18
  export { resolveArchiveKind, resolvePackedRootDir } from "./archive-kind.js";
16
19
  export { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, DEFAULT_MAX_ARCHIVE_BYTES_ZIP, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_EXTRACTED_BYTES, DEFAULT_MAX_ENTRY_BYTES, } from "./archive-limits.js";
@@ -29,6 +32,85 @@ function isZipSymlinkEntry(entry) {
29
32
  return (typeof entry.unixPermissions === "number" &&
30
33
  (entry.unixPermissions & ZIP_UNIX_FILE_TYPE_MASK) === ZIP_UNIX_SYMLINK_TYPE);
31
34
  }
35
+ function zipEntryFileMode(entry) {
36
+ if (typeof entry.unixPermissions !== "number") {
37
+ return undefined;
38
+ }
39
+ const mode = entry.unixPermissions & 0o777;
40
+ return mode === 0 ? undefined : mode;
41
+ }
42
+ async function cleanupStagedArchiveFile(staged) {
43
+ if (staged) {
44
+ await staged.cleanup().catch(() => undefined);
45
+ }
46
+ }
47
+ async function closeFileHandle(handle) {
48
+ if (handle) {
49
+ await handle.close().catch(() => undefined);
50
+ }
51
+ }
52
+ async function stageArchiveFileForExtraction(params) {
53
+ params.deadline.check();
54
+ const sourcePath = path.resolve(params.archivePath);
55
+ const initialStat = await fs.lstat(sourcePath);
56
+ if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
57
+ throw new Error(`archive is not a regular file: ${params.archivePath}`);
58
+ }
59
+ if (initialStat.size > params.limits.maxArchiveBytes) {
60
+ throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
61
+ }
62
+ const noFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants ? fsConstants.O_NOFOLLOW : 0;
63
+ const handle = await fs.open(sourcePath, fsConstants.O_RDONLY | noFollow);
64
+ let staged;
65
+ let output;
66
+ try {
67
+ staged = await tempFile({
68
+ prefix: "fs-safe-archive-input",
69
+ fileName: path.basename(sourcePath),
70
+ });
71
+ const openedStat = await handle.stat();
72
+ const pathStat = await fs.lstat(sourcePath);
73
+ if (!openedStat.isFile() ||
74
+ pathStat.isSymbolicLink() ||
75
+ !pathStat.isFile() ||
76
+ !sameFileIdentity(initialStat, openedStat) ||
77
+ !sameFileIdentity(pathStat, openedStat)) {
78
+ throw new Error("archive changed during validation");
79
+ }
80
+ output = await fs.open(staged.path, OPEN_WRITE_CREATE_FLAGS, 0o600);
81
+ const buffer = Buffer.allocUnsafe(64 * 1024);
82
+ let written = 0;
83
+ while (true) {
84
+ params.deadline.check();
85
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
86
+ if (bytesRead === 0) {
87
+ break;
88
+ }
89
+ written += bytesRead;
90
+ if (written > params.limits.maxArchiveBytes) {
91
+ throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
92
+ }
93
+ await writeFileHandleFully({
94
+ handle: output,
95
+ buffer,
96
+ bytes: bytesRead,
97
+ deadline: params.deadline,
98
+ });
99
+ params.deadline.check();
100
+ }
101
+ await output.close();
102
+ output = undefined;
103
+ return staged;
104
+ }
105
+ catch (err) {
106
+ await closeFileHandle(output);
107
+ await cleanupStagedArchiveFile(staged);
108
+ throw err;
109
+ }
110
+ finally {
111
+ await closeFileHandle(handle);
112
+ }
113
+ }
32
114
  async function readZipEntryStream(entry) {
33
115
  if (typeof entry.nodeStream === "function") {
34
116
  return entry.nodeStream();
@@ -57,6 +139,7 @@ async function prepareZipOutputPath(params) {
57
139
  await prepareArchiveOutputPath(params);
58
140
  }
59
141
  async function writeZipFileEntry(params) {
142
+ params.deadline.check();
60
143
  params.budget.startEntry();
61
144
  const readable = await readZipEntryStream(params.entry);
62
145
  const destinationPath = params.outPath;
@@ -67,13 +150,20 @@ async function writeZipFileEntry(params) {
67
150
  dir: path.dirname(destinationPath),
68
151
  tempPrefix: `.${path.basename(destinationPath)}.fs-safe-archive`,
69
152
  chmodDir: false,
153
+ mode: zipEntryFileMode(params.entry),
70
154
  writeTemp: async (tempPath) => {
71
155
  tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, 0o666);
72
156
  const writable = tempHandle.createWriteStream();
73
157
  writable.once("close", () => {
74
158
  handleClosedByStream = true;
75
159
  });
76
- await pipeline(readable, createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }), writable);
160
+ try {
161
+ await pipeline(readable, createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }), writable, { signal: params.deadline.signal });
162
+ }
163
+ catch (err) {
164
+ throw createPipelineTimeoutError(err, params.deadline);
165
+ }
166
+ params.deadline.check();
77
167
  if (!handleClosedByStream) {
78
168
  await tempHandle.close().catch(() => undefined);
79
169
  handleClosedByStream = true;
@@ -83,13 +173,6 @@ async function writeZipFileEntry(params) {
83
173
  },
84
174
  resolveFinalPath: (filePath) => filePath,
85
175
  });
86
- // Best-effort permission restore for zip entries created on unix.
87
- if (typeof params.entry.unixPermissions === "number") {
88
- const mode = params.entry.unixPermissions & 0o777;
89
- if (mode !== 0) {
90
- await fs.chmod(destinationPath, mode).catch(() => undefined);
91
- }
92
- }
93
176
  }
94
177
  catch (err) {
95
178
  // Failures here happen before the temp has been committed. The destination
@@ -105,57 +188,70 @@ async function writeZipFileEntry(params) {
105
188
  }
106
189
  async function extractZip(params) {
107
190
  const limits = resolveExtractLimits(params.limits);
108
- const destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
109
- const stat = await fs.stat(params.archivePath);
110
- if (stat.size > limits.maxArchiveBytes) {
111
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
112
- }
113
- const buffer = await fs.readFile(params.archivePath);
114
- const zip = await loadZipArchiveWithPreflight(buffer, limits);
115
- const entries = Object.values(zip.files);
116
- const strip = Math.max(0, Math.floor(params.stripComponents ?? 0));
117
- assertArchiveEntryCountWithinLimit(entries.length, limits);
118
- const budget = createByteBudgetTracker(limits);
119
- await withStagedArchiveDestination({
120
- destinationRealDir,
121
- run: async (stagingDir) => {
122
- const stagingRealDir = await fs.realpath(stagingDir);
123
- for (const entry of entries) {
124
- const output = resolveZipOutputPath({
125
- entryPath: entry.name,
126
- strip,
127
- destinationDir: stagingRealDir,
128
- });
129
- if (!output) {
130
- continue;
131
- }
132
- await prepareZipOutputPath({
133
- destinationDir: stagingRealDir,
134
- destinationRealDir: stagingRealDir,
135
- relPath: output.relPath,
136
- outPath: output.outPath,
137
- originalPath: entry.name,
138
- isDirectory: entry.dir,
139
- });
140
- if (entry.dir) {
141
- continue;
142
- }
143
- if (isZipSymlinkEntry(entry)) {
144
- throw new Error(`zip entry is a link: ${entry.name}`);
191
+ const stagedArchive = await stageArchiveFileForExtraction({
192
+ archivePath: params.archivePath,
193
+ limits,
194
+ deadline: params.deadline,
195
+ });
196
+ try {
197
+ const destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
198
+ params.deadline.check();
199
+ const buffer = await fs.readFile(stagedArchive.path, { signal: params.deadline.signal });
200
+ params.deadline.check();
201
+ const zip = await waitForDeadline(loadZipArchiveWithPreflight(buffer, limits), params.deadline);
202
+ params.deadline.check();
203
+ const entries = Object.values(zip.files);
204
+ const strip = Math.max(0, Math.floor(params.stripComponents ?? 0));
205
+ assertArchiveEntryCountWithinLimit(entries.length, limits);
206
+ const budget = createByteBudgetTracker(limits);
207
+ await withStagedArchiveDestination({
208
+ destinationRealDir,
209
+ run: async (stagingDir) => {
210
+ const stagingRealDir = await fs.realpath(stagingDir);
211
+ for (const entry of entries) {
212
+ params.deadline.check();
213
+ const output = resolveZipOutputPath({
214
+ entryPath: entry.name,
215
+ strip,
216
+ destinationDir: stagingRealDir,
217
+ });
218
+ if (!output) {
219
+ continue;
220
+ }
221
+ await prepareZipOutputPath({
222
+ destinationDir: stagingRealDir,
223
+ destinationRealDir: stagingRealDir,
224
+ relPath: output.relPath,
225
+ outPath: output.outPath,
226
+ originalPath: entry.name,
227
+ isDirectory: entry.dir,
228
+ });
229
+ if (entry.dir) {
230
+ continue;
231
+ }
232
+ if (isZipSymlinkEntry(entry)) {
233
+ throw new Error(`zip entry is a link: ${entry.name}`);
234
+ }
235
+ await writeZipFileEntry({
236
+ entry,
237
+ outPath: output.outPath,
238
+ budget,
239
+ deadline: params.deadline,
240
+ });
145
241
  }
146
- await writeZipFileEntry({
147
- entry,
148
- outPath: output.outPath,
149
- budget,
242
+ params.deadline.check();
243
+ await mergeExtractedTreeIntoDestination({
244
+ sourceDir: stagingRealDir,
245
+ destinationDir: params.destDir,
246
+ destinationRealDir,
150
247
  });
151
- }
152
- await mergeExtractedTreeIntoDestination({
153
- sourceDir: stagingRealDir,
154
- destinationDir: params.destDir,
155
- destinationRealDir,
156
- });
157
- },
158
- });
248
+ params.deadline.check();
249
+ },
250
+ });
251
+ }
252
+ finally {
253
+ await stagedArchive.cleanup();
254
+ }
159
255
  }
160
256
  export async function extractArchive(params) {
161
257
  const kind = params.kind ?? resolveArchiveKind(params.archivePath);
@@ -164,62 +260,74 @@ export async function extractArchive(params) {
164
260
  }
165
261
  const label = kind === "zip" ? "extract zip" : "extract tar";
166
262
  if (kind === "tar") {
167
- await withTimeout((async () => {
263
+ await withExtractionDeadline(params.timeoutMs, label, async (deadline) => {
168
264
  const tar = await importOptionalTar();
169
265
  const limits = resolveExtractLimits(params.limits);
170
- const stat = await fs.stat(params.archivePath);
171
- if (stat.size > limits.maxArchiveBytes) {
172
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
173
- }
174
- const destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
175
- await withStagedArchiveDestination({
176
- destinationRealDir,
177
- run: async (stagingDir) => {
178
- const checkTarEntrySafety = createTarEntryPreflightChecker({
179
- rootDir: destinationRealDir,
180
- stripComponents: params.stripComponents,
181
- limits,
182
- });
183
- // A canonical cwd is not enough here: tar can still follow
184
- // pre-existing child symlinks in the live destination tree.
185
- // Extract into a private staging dir first, then merge through
186
- // the same safe-open boundary checks used by direct file writes.
187
- await tar.x({
188
- file: params.archivePath,
189
- cwd: stagingDir,
190
- strip: Math.max(0, Math.floor(params.stripComponents ?? 0)),
191
- gzip: params.tarGzip,
192
- preservePaths: false,
193
- strict: true,
194
- onReadEntry(entry) {
195
- try {
196
- checkTarEntrySafety(readTarEntryInfo(entry));
197
- }
198
- catch (err) {
199
- const error = err instanceof Error ? err : new Error(String(err));
200
- // Node's EventEmitter calls listeners with `this` bound to the
201
- // emitter (tar.Unpack), which exposes Parser.abort().
202
- const emitter = this;
203
- emitter.abort?.(error);
204
- }
205
- },
206
- });
207
- await mergeExtractedTreeIntoDestination({
208
- sourceDir: stagingDir,
209
- destinationDir: params.destDir,
210
- destinationRealDir,
211
- });
212
- },
266
+ const stagedArchive = await stageArchiveFileForExtraction({
267
+ archivePath: params.archivePath,
268
+ limits,
269
+ deadline,
213
270
  });
214
- })(), params.timeoutMs, label);
271
+ try {
272
+ const destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
273
+ await withStagedArchiveDestination({
274
+ destinationRealDir,
275
+ run: async (stagingDir) => {
276
+ deadline.check();
277
+ const checkTarEntrySafety = createTarEntryPreflightChecker({
278
+ rootDir: destinationRealDir,
279
+ stripComponents: params.stripComponents,
280
+ limits,
281
+ });
282
+ // A canonical cwd is not enough here: tar can still follow
283
+ // pre-existing child symlinks in the live destination tree.
284
+ // Extract into a private staging dir first, then merge through
285
+ // the same safe-open boundary checks used by direct file writes.
286
+ await tar.x({
287
+ file: stagedArchive.path,
288
+ cwd: stagingDir,
289
+ strip: Math.max(0, Math.floor(params.stripComponents ?? 0)),
290
+ gzip: params.tarGzip,
291
+ signal: deadline.signal,
292
+ preservePaths: false,
293
+ strict: true,
294
+ onReadEntry(entry) {
295
+ try {
296
+ deadline.check();
297
+ checkTarEntrySafety(readTarEntryInfo(entry));
298
+ }
299
+ catch (err) {
300
+ const error = err instanceof Error ? err : new Error(String(err));
301
+ // Node's EventEmitter calls listeners with `this` bound to the
302
+ // emitter (tar.Unpack), which exposes Parser.abort().
303
+ const emitter = this;
304
+ emitter.abort?.(error);
305
+ }
306
+ },
307
+ });
308
+ deadline.check();
309
+ await mergeExtractedTreeIntoDestination({
310
+ sourceDir: stagingDir,
311
+ destinationDir: params.destDir,
312
+ destinationRealDir,
313
+ });
314
+ deadline.check();
315
+ },
316
+ });
317
+ }
318
+ finally {
319
+ await stagedArchive.cleanup();
320
+ }
321
+ });
215
322
  return;
216
323
  }
217
- await withTimeout(extractZip({
324
+ await withExtractionDeadline(params.timeoutMs, label, async (deadline) => extractZip({
218
325
  archivePath: params.archivePath,
219
326
  destDir: params.destDir,
220
327
  stripComponents: params.stripComponents,
221
328
  limits: params.limits,
222
- }), params.timeoutMs, label);
329
+ deadline,
330
+ }));
223
331
  }
224
332
  async function importOptionalTar() {
225
333
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAM5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+KD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOhB;AAiDD,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAED,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,MAAM,EAAE;IACzD,KAAK,EAAE,0BAA0B,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAwBpB;AAED,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,CAAC,EAAE,CAAC,CAkDd;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB"}
1
+ {"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAM5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+KD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOhB;AAsDD,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAED,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,MAAM,EAAE;IACzD,KAAK,EAAE,0BAA0B,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAwBpB;AAED,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,CAAC,EAAE,CAAC,CAkDd;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB"}
@@ -215,6 +215,9 @@ async function readBoundedUtf8File(params) {
215
215
  if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
216
216
  throw new Error("queue entry is not a regular file");
217
217
  }
218
+ if (initialStat.nlink > 1) {
219
+ throw new Error("queue entry hardlinks are not allowed");
220
+ }
218
221
  if (initialStat.size > params.maxBytes) {
219
222
  throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
220
223
  }
@@ -228,6 +231,8 @@ async function readBoundedUtf8File(params) {
228
231
  if (!openedStat.isFile() ||
229
232
  pathStat.isSymbolicLink() ||
230
233
  !pathStat.isFile() ||
234
+ openedStat.nlink > 1 ||
235
+ pathStat.nlink > 1 ||
231
236
  !sameFileIdentity(initialStat, openedStat) ||
232
237
  !sameFileIdentity(pathStat, openedStat)) {
233
238
  throw new Error("queue entry changed during read");
@@ -1 +1 @@
1
- {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,CA8BrC"}
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,CA+BrC"}
package/dist/output.js CHANGED
@@ -42,6 +42,7 @@ export async function writeExternalFileWithinRoot(options) {
42
42
  if (requestedTargetPath.length === 0) {
43
43
  throw new FsSafeError("invalid-path", "target path is required");
44
44
  }
45
+ assertFileTargetPath(requestedTargetPath);
45
46
  const targetPath = toRootPathInput({
46
47
  rootDir: targetRoot.rootDir,
47
48
  rootReal: targetRoot.rootReal,
@@ -67,7 +67,7 @@ export async function runPinnedWriteHelper(params) {
67
67
  relativeParentPath: params.relativeParentPath,
68
68
  });
69
69
  if (getFsSafePythonConfig().mode === "off") {
70
- return await runPinnedWriteFallbackOrThrow(params);
70
+ return await runPinnedWriteFallback(params);
71
71
  }
72
72
  if (params.input.kind === "stream") {
73
73
  try {
@@ -75,7 +75,7 @@ export async function runPinnedWriteHelper(params) {
75
75
  }
76
76
  catch (error) {
77
77
  if (canFallbackFromPythonError(error)) {
78
- return await runPinnedWriteFallbackOrThrow(params, error);
78
+ return await runPinnedWriteFallback(params);
79
79
  }
80
80
  throw error;
81
81
  }
@@ -99,7 +99,7 @@ export async function runPinnedWriteHelper(params) {
99
99
  }
100
100
  catch (error) {
101
101
  if (canFallbackFromPythonError(error)) {
102
- return await runPinnedWriteFallbackOrThrow(params, error);
102
+ return await runPinnedWriteFallback(params);
103
103
  }
104
104
  throw error;
105
105
  }
@@ -126,12 +126,6 @@ export async function runPinnedCopyHelper(params) {
126
126
  },
127
127
  });
128
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
129
  async function runPinnedWriteFallback(params) {
136
130
  const parentPath = params.relativeParentPath
137
131
  ? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
@@ -1 +1 @@
1
- {"version":3,"file":"root-paths.d.ts","sourceRoot":"","sources":["../src/root-paths.ts"],"names":[],"mappings":"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"}
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)) {
@@ -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":"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"}
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
  }
@@ -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
 
package/docs/root.md CHANGED
@@ -102,14 +102,15 @@ operations that Node's `fs` API does not expose ergonomically.
102
102
  ```ts
103
103
  import { configureFsSafePython } from "@openclaw/fs-safe/config";
104
104
 
105
- configureFsSafePython({ mode: "off" }); // disable helper; some writes fail closed
105
+ configureFsSafePython({ mode: "off" }); // Node-only fallback path
106
106
  configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable
107
107
  ```
108
108
 
109
- `auto` is the default. Configure the mode before creating roots. On POSIX,
110
- write methods that require fd-relative parent commits fail closed without the
111
- helper. Use `require` when any helper loss should be treated as a deployment
112
- failure. See [Python helper policy](python-helper.md) for deployment guidance.
109
+ `auto` is the default. Configure the mode before creating roots. Without the
110
+ helper, root methods still run, but same-UID races that swap parent directories
111
+ between validation and mutation are harder to close completely. Use `require`
112
+ when that downgrade should be treated as a deployment failure. See
113
+ [Python helper policy](python-helper.md) for deployment guidance.
113
114
 
114
115
  ### Properties
115
116
 
@@ -36,7 +36,7 @@ The 16 KiB cap is intentionally aggressive — credentials should be small. If y
36
36
 
37
37
  ### `tryReadSecretFileSync(filePath, label, options?)`
38
38
 
39
- The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing, empty, unreadable, too large, or rejected by the validation checks.
39
+ The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing or blank. Validation failures, unreadable files, oversized files, symlinks, and hardlinks throw `FsSafeError` so callers fail closed on suspicious credential state.
40
40
 
41
41
  ```ts
42
42
  import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
@@ -63,10 +63,11 @@ const token = readSecretFileSync("/var/lib/app/auth.token");
63
63
  type SecretFileReadOptions = {
64
64
  maxBytes?: number; // default DEFAULT_SECRET_FILE_MAX_BYTES (16 KiB)
65
65
  rejectSymlink?: boolean;
66
+ rejectHardlinks?: boolean; // default true
66
67
  };
67
68
  ```
68
69
 
69
- The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read.
70
+ The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read. Hardlinks are rejected by default so another in-tree name cannot alias the credential; pass `rejectHardlinks: false` only when you explicitly trust that layout.
70
71
 
71
72
  ## Writing
72
73
 
package/docs/timing.md CHANGED
@@ -102,7 +102,7 @@ await extractArchive({
102
102
  });
103
103
  ```
104
104
 
105
- `extractArchive` already takes `timeoutMs` and uses `withTimeout` internally — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
105
+ `extractArchive` already takes `timeoutMs` and carries its own abort signal/deadline checks — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
106
106
 
107
107
  ### Disable in tests
108
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {