@openclaw/fs-safe 0.2.5 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6 - 2026-05-17
4
+
5
+ ### Security and Correctness
6
+
7
+ - Harden DeepSec-reported temp path handling, private secret writes, pinned helper commits, fallback mkdir writes, and dot-prefixed root relative paths against symlink, race, and relative-path edge cases.
8
+
3
9
  ## 0.2.5 - 2026-05-16
4
10
 
5
11
  ### Security and Correctness
package/README.md CHANGED
@@ -66,18 +66,15 @@ 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; use Node fallbacks
69
+ configureFsSafePython({ mode: "off" }); // never spawn Python; writes that need it fail closed
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`. 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.
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.
81
78
 
82
79
  ## Quick start
83
80
 
@@ -435,7 +432,7 @@ Current `FsSafeErrorCode` values are `already-exists`, `hardlink`, `helper-faile
435
432
  - root-bounded APIs resolve paths against a configured root and reject canonical escapes
436
433
  - reads open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
437
434
  - writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
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
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
439
436
  - archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
440
437
 
441
438
  ## Limitations
@@ -5,7 +5,7 @@ import { Transform } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { assertSyncDirectoryGuard as assertDirectoryGuardSync, createSyncDirectoryGuard, } from "./directory-guard.js";
7
7
  import { FsSafeError } from "./errors.js";
8
- import { isPathInside } from "./path.js";
8
+ import { isPathInside, isPathRelativeEscape } from "./path.js";
9
9
  import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
10
10
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
11
11
  function parentRelativePath(relativePath) {
@@ -131,7 +131,7 @@ export function ensureParentSync(params) {
131
131
  const rootDir = path.resolve(params.rootDir);
132
132
  const dir = path.dirname(path.resolve(params.filePath));
133
133
  const relative = path.relative(rootDir, dir);
134
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
134
+ if (isPathRelativeEscape(relative)) {
135
135
  throw new FsSafeError("outside-workspace", "file path escapes store root");
136
136
  }
137
137
  syncFs.mkdirSync(rootDir, { recursive: true, mode: params.mode });
@@ -1 +1 @@
1
- {"version":3,"file":"guarded-mkdir.d.ts","sourceRoot":"","sources":["../src/guarded-mkdir.ts"],"names":[],"mappings":"AAaA,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACnE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiChB"}
1
+ {"version":3,"file":"guarded-mkdir.d.ts","sourceRoot":"","sources":["../src/guarded-mkdir.ts"],"names":[],"mappings":"AAWA,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACnE,GAAG,OAAO,CAAC,IAAI,CAAC,CAkChB"}
@@ -2,17 +2,17 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
4
4
  import { FsSafeError } from "./errors.js";
5
+ import { isPathRelativeEscape } from "./path.js";
5
6
  function isSameOrChildPath(candidate, parent) {
6
- return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
7
- }
8
- function isPathEscape(relativePath) {
9
- return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
7
+ const parentPrefix = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
8
+ return candidate === parent || candidate.startsWith(parentPrefix);
10
9
  }
11
10
  export async function mkdirPathComponentsWithGuards(params) {
12
11
  const root = path.resolve(params.rootReal);
12
+ const rootCanonical = path.resolve(await fs.realpath(root));
13
13
  const target = path.resolve(params.targetPath);
14
14
  const relative = path.relative(root, target);
15
- if (isPathEscape(relative)) {
15
+ if (isPathRelativeEscape(relative)) {
16
16
  throw new FsSafeError("outside-workspace", "directory is outside workspace root");
17
17
  }
18
18
  let current = root;
@@ -35,7 +35,7 @@ export async function mkdirPathComponentsWithGuards(params) {
35
35
  }
36
36
  // Node's recursive mkdir follows symlinks in missing components. Build one
37
37
  // segment at a time and realpath-check each segment before descending.
38
- if (!isSameOrChildPath(path.resolve(await fs.realpath(next)), root)) {
38
+ if (!isSameOrChildPath(path.resolve(await fs.realpath(next)), rootCanonical)) {
39
39
  throw new FsSafeError("outside-workspace", "directory escaped workspace root");
40
40
  }
41
41
  await createAsyncDirectoryGuard(next);
package/dist/path.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare function assertNoNulPathInput(filePath: string, message?: string)
6
6
  export declare function isNotFoundPathError(value: unknown): boolean;
7
7
  export declare function isSymlinkOpenError(value: unknown): boolean;
8
8
  export declare function isPathInside(root: string, target: string): boolean;
9
+ export declare function isPathRelativeEscape(relativePath: string): boolean;
9
10
  export declare function resolveSafeBaseDir(rootDir: string): string;
10
11
  export declare function isWithinDir(rootDir: string, targetPath: string): boolean;
11
12
  export declare function safeRealpathSync(targetPath: string, cache?: Map<string, string>): string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAUzB,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAI1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAA6B,GAAG,IAAI,CAIjG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BlE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChE,OAAO,CAUT;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK,GAAG,IAAI,CAMhE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAOrF"}
1
+ {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAUzB,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAI1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAA6B,GAAG,IAAI,CAIjG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BlE;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAElE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChE,OAAO,CAUT;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK,GAAG,IAAI,CAMhE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAOrF"}
package/dist/path.js CHANGED
@@ -55,6 +55,9 @@ export function isPathInside(root, target) {
55
55
  const firstSegment = relative.split(path.posix.sep)[0];
56
56
  return relative === "" || (firstSegment !== ".." && !path.isAbsolute(relative));
57
57
  }
58
+ export function isPathRelativeEscape(relativePath) {
59
+ return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
60
+ }
58
61
  export function resolveSafeBaseDir(rootDir) {
59
62
  const resolved = path.resolve(rootDir);
60
63
  return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"AAwZA,KAAK,qBAAqB,GACtB,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,CAAC;AAiBZ,wBAAgB,gCAAgC,IAAI,IAAI,CAQvD;AA2ND,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,qBAAqB,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4Bb;AAED,wBAAgB,oCAAoC,IAAI,IAAI,CAK3D;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAarF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIjE"}
1
+ {"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"AA2ZA,KAAK,qBAAqB,GACtB,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,CAAC;AAiBZ,wBAAgB,gCAAgC,IAAI,IAAI,CAQvD;AAiOD,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,qBAAqB,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4Bb;AAED,wBAAgB,oCAAoC,IAAI,IAAI,CAK3D;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAarF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEjE"}
@@ -3,26 +3,20 @@ import fsSync from "node:fs";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { getFsSafePythonConfig } from "./pinned-python-config.js";
5
5
  const PINNED_PYTHON_WORKER_SOURCE = String.raw `
6
- import base64
7
- import errno
8
- import json
9
- import os
10
- import secrets
11
- import stat
12
- import sys
13
-
6
+ import base64, errno, json, os, secrets, stat, sys
14
7
  DIR_FLAGS = os.O_RDONLY
15
8
  if hasattr(os, "O_DIRECTORY"):
16
9
  DIR_FLAGS |= os.O_DIRECTORY
17
10
  if hasattr(os, "O_NOFOLLOW"):
18
11
  DIR_FLAGS |= os.O_NOFOLLOW
19
12
  READ_FLAGS = os.O_RDONLY
13
+ if hasattr(os, "O_NONBLOCK"):
14
+ READ_FLAGS |= os.O_NONBLOCK
20
15
  if hasattr(os, "O_NOFOLLOW"):
21
16
  READ_FLAGS |= os.O_NOFOLLOW
22
17
  WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
23
18
  if hasattr(os, "O_NOFOLLOW"):
24
19
  WRITE_FLAGS |= os.O_NOFOLLOW
25
-
26
20
  def split_relative(value):
27
21
  if value in ("", "."):
28
22
  return []
@@ -35,10 +29,8 @@ def split_relative(value):
35
29
  if part == "..":
36
30
  raise OSError(errno.EPERM, "path traversal is not allowed")
37
31
  return parts
38
-
39
32
  def open_dir(path_value, dir_fd=None):
40
33
  return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)
41
-
42
34
  def walk_dir(root_fd, segments, mkdir_enabled=False):
43
35
  current_fd = os.dup(root_fd)
44
36
  try:
@@ -56,14 +48,12 @@ def walk_dir(root_fd, segments, mkdir_enabled=False):
56
48
  except Exception:
57
49
  os.close(current_fd)
58
50
  raise
59
-
60
51
  def parent_and_basename(root_fd, relative):
61
52
  segments = split_relative(relative)
62
53
  if not segments:
63
54
  raise OSError(errno.EPERM, "operation requires a non-root path")
64
55
  parent_fd = walk_dir(root_fd, segments[:-1])
65
56
  return parent_fd, segments[-1]
66
-
67
57
  def encode_stat(st):
68
58
  mode = st.st_mode
69
59
  return {
@@ -79,14 +69,12 @@ def encode_stat(st):
79
69
  "size": st.st_size,
80
70
  "uid": st.st_uid,
81
71
  }
82
-
83
72
  def reject_unsafe_endpoint(st):
84
73
  mode = st.st_mode
85
74
  if stat.S_ISLNK(mode):
86
75
  raise OSError(errno.ELOOP, "symlink endpoint is not allowed")
87
76
  if stat.S_ISREG(mode) and st.st_nlink > 1:
88
77
  raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed")
89
-
90
78
  def copy_bytes(source_fd, dest_fd):
91
79
  while True:
92
80
  chunk = os.read(source_fd, 65536)
@@ -98,7 +86,6 @@ def copy_bytes(source_fd, dest_fd):
98
86
  if written <= 0:
99
87
  raise OSError(errno.EIO, "short write")
100
88
  view = view[written:]
101
-
102
89
  def write_all(fd, data):
103
90
  view = memoryview(data)
104
91
  while view:
@@ -106,11 +93,9 @@ def write_all(fd, data):
106
93
  if written <= 0:
107
94
  raise OSError(errno.EIO, "short write")
108
95
  view = view[written:]
109
-
110
96
  def link_unsupported(exc):
111
97
  unsupported = (errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP))
112
98
  return getattr(exc, "errno", None) in unsupported
113
-
114
99
  def link_no_replace(name, new_name, source_fd, target_fd):
115
100
  linked = False
116
101
  try:
@@ -125,11 +110,9 @@ def link_no_replace(name, new_name, source_fd, target_fd):
125
110
  os.fsync(source_fd)
126
111
  if source_fd != target_fd:
127
112
  os.fsync(target_fd)
128
-
129
113
  def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basename, mode, expected=None, unlink_source=False):
130
114
  source_fd = os.open(source_name, READ_FLAGS, dir_fd=source_parent_fd)
131
- dest_fd = None
132
- success = False
115
+ dest_fd = None; success = False; dest_stat = None
133
116
  try:
134
117
  if expected is not None:
135
118
  source_stat = os.fstat(source_fd)
@@ -138,6 +121,7 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
138
121
  dest_fd = os.open(basename, WRITE_FLAGS, mode, dir_fd=target_parent_fd)
139
122
  copy_bytes(source_fd, dest_fd)
140
123
  os.fsync(dest_fd)
124
+ dest_stat = os.fstat(dest_fd)
141
125
  success = True
142
126
  finally:
143
127
  os.close(source_fd)
@@ -153,19 +137,40 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
153
137
  try: os.unlink(basename, dir_fd=target_parent_fd)
154
138
  except FileNotFoundError: pass
155
139
  raise
156
-
157
- def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode):
140
+ return dest_stat
141
+ def same_identity(left, right):
142
+ return left.st_dev == right.st_dev and left.st_ino == right.st_ino
143
+ def verify_temp_name(parent_fd, temp_name, expected_stat):
144
+ current_stat = os.lstat(temp_name, dir_fd=parent_fd)
145
+ if stat.S_ISLNK(current_stat.st_mode) or not same_identity(current_stat, expected_stat):
146
+ raise RuntimeError("fs-safe-temp-mismatch")
147
+ def verify_committed_temp(parent_fd, basename, expected_stat):
148
+ final_stat = os.lstat(basename, dir_fd=parent_fd)
149
+ if not stat.S_ISLNK(final_stat.st_mode) and same_identity(final_stat, expected_stat):
150
+ return final_stat
151
+ try: os.unlink(basename, dir_fd=parent_fd)
152
+ except FileNotFoundError: pass
153
+ raise RuntimeError("fs-safe-temp-mismatch")
154
+ def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, expected_stat):
155
+ verify_temp_name(parent_fd, temp_name, expected_stat)
158
156
  if overwrite:
159
157
  os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
158
+ return verify_committed_temp(parent_fd, basename, expected_stat)
160
159
  else:
161
160
  try:
162
161
  os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)
162
+ final_stat = verify_committed_temp(parent_fd, basename, expected_stat)
163
163
  os.unlink(temp_name, dir_fd=parent_fd)
164
+ return final_stat
164
165
  except OSError as exc:
165
166
  if not link_unsupported(exc):
166
167
  raise
167
- copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, unlink_source=True)
168
-
168
+ return copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, expected_stat, True)
169
+ def assert_expected_root(root_fd, payload):
170
+ if "rootDev" in payload or "rootIno" in payload:
171
+ root_stat = os.fstat(root_fd)
172
+ if root_stat.st_dev != int(payload["rootDev"]) or root_stat.st_ino != int(payload["rootIno"]):
173
+ raise RuntimeError("fs-safe-root-mismatch")
169
174
  def stat_path(root_fd, payload):
170
175
  relative = payload.get("relativePath", "")
171
176
  segments = split_relative(relative)
@@ -179,7 +184,6 @@ def stat_path(root_fd, payload):
179
184
  return encode_stat(st)
180
185
  finally:
181
186
  os.close(parent_fd)
182
-
183
187
  def readdir_path(root_fd, payload):
184
188
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")))
185
189
  try:
@@ -195,12 +199,9 @@ def readdir_path(root_fd, payload):
195
199
  return entries
196
200
  finally:
197
201
  os.close(dir_fd)
198
-
199
202
  def mkdirp_path(root_fd, payload):
200
203
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")), mkdir_enabled=True)
201
- os.close(dir_fd)
202
- return None
203
-
204
+ os.close(dir_fd); return None
204
205
  def remove_tree(parent_fd, basename):
205
206
  st = os.lstat(basename, dir_fd=parent_fd)
206
207
  if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode):
@@ -213,7 +214,6 @@ def remove_tree(parent_fd, basename):
213
214
  os.rmdir(basename, dir_fd=parent_fd)
214
215
  else:
215
216
  os.unlink(basename, dir_fd=parent_fd)
216
-
217
217
  def remove_path(root_fd, payload):
218
218
  parent_fd, basename = parent_and_basename(root_fd, payload.get("relativePath", ""))
219
219
  try:
@@ -296,14 +296,15 @@ def write_path(root_fd, payload):
296
296
  except FileNotFoundError:
297
297
  pass
298
298
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
299
+ os.fchmod(temp_fd, mode)
299
300
  write_all(temp_fd, data)
300
301
  os.fsync(temp_fd)
302
+ temp_stat = os.fstat(temp_fd)
301
303
  os.close(temp_fd)
302
304
  temp_fd = None
303
- commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
305
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
304
306
  temp_name = None
305
307
  os.fsync(parent_fd)
306
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
307
308
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
308
309
  finally:
309
310
  if temp_fd is not None:
@@ -334,6 +335,7 @@ def copy_path(root_fd, payload):
334
335
  raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size))
335
336
  parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True)))
336
337
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
338
+ os.fchmod(temp_fd, mode)
337
339
  written_bytes = 0
338
340
  while True:
339
341
  chunk = os.read(source_fd, 65536)
@@ -349,12 +351,12 @@ def copy_path(root_fd, payload):
349
351
  raise OSError(errno.EIO, "short write")
350
352
  view = view[written:]
351
353
  os.fsync(temp_fd)
354
+ temp_stat = os.fstat(temp_fd)
352
355
  os.close(temp_fd)
353
356
  temp_fd = None
354
- commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
357
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
355
358
  temp_name = None
356
359
  os.fsync(parent_fd)
357
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
358
360
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
359
361
  finally:
360
362
  os.close(source_fd)
@@ -371,6 +373,7 @@ def copy_path(root_fd, payload):
371
373
  def run_operation(operation, root_path, payload):
372
374
  root_fd = open_dir(root_path)
373
375
  try:
376
+ assert_expected_root(root_fd, payload)
374
377
  if operation == "stat":
375
378
  return stat_path(root_fd, payload)
376
379
  if operation == "readdir":
@@ -475,6 +478,12 @@ function mapWorkerError(response) {
475
478
  if (message.includes("fs-safe-source-mismatch")) {
476
479
  return new FsSafeError("path-mismatch", "source path changed during copy");
477
480
  }
481
+ if (message.includes("fs-safe-temp-mismatch")) {
482
+ return new FsSafeError("path-mismatch", "temp path changed during write");
483
+ }
484
+ if (message.includes("fs-safe-root-mismatch")) {
485
+ return new FsSafeError("path-mismatch", "root path changed during operation");
486
+ }
478
487
  if (message.includes("fs-safe-directory-noreplace-unsupported")) {
479
488
  return new FsSafeError("invalid-path", "directory moves require overwrite: true");
480
489
  }
@@ -655,9 +664,7 @@ export function validatePinnedOperationPayload(payload) {
655
664
  }
656
665
  }
657
666
  export function isPinnedHelperUnavailable(error) {
658
- return error instanceof Error &&
659
- "code" in error &&
660
- error.code === "helper-unavailable";
667
+ return error instanceof Error && "code" in error && error.code === "helper-unavailable";
661
668
  }
662
669
  function validatePinnedRelativePath(relativePath) {
663
670
  if (relativePath.length === 0 || relativePath === ".") {
@@ -1,4 +1,4 @@
1
- import { type Readable } from "node:stream";
1
+ import type { Readable } from "node:stream";
2
2
  import type { FileIdentityStat } from "./file-identity.js";
3
3
  type PinnedWriteInput = {
4
4
  kind: "buffer";
@@ -17,6 +17,7 @@ export declare function runPinnedWriteHelper(params: {
17
17
  overwrite?: boolean;
18
18
  maxBytes?: number;
19
19
  input: PinnedWriteInput;
20
+ rootIdentity?: FileIdentityStat;
20
21
  }): Promise<FileIdentityStat>;
21
22
  export declare function runPinnedCopyHelper(params: {
22
23
  rootPath: string;
@@ -28,6 +29,7 @@ export declare function runPinnedCopyHelper(params: {
28
29
  maxBytes?: number;
29
30
  sourcePath: string;
30
31
  sourceIdentity: FileIdentityStat;
32
+ rootIdentity?: FileIdentityStat;
31
33
  }): Promise<FileIdentityStat>;
32
34
  export {};
33
35
  //# sourceMappingURL=pinned-write.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAIA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAS3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAuFzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;CAClC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoB5B"}
1
+ {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAG5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAW3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AA6EzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;IACxB,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAwC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;IACjC,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqB5B"}
@@ -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
  }
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;
@@ -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
  }
@@ -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);
@@ -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
 
package/docs/root.md CHANGED
@@ -102,15 +102,14 @@ 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" }); // Node-only fallback path
105
+ configureFsSafePython({ mode: "off" }); // disable helper; some writes fail closed
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. 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.
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.
114
113
 
115
114
  ### Properties
116
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {