@openclaw/fs-safe 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +15 -5
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-plan.d.ts +2 -7
  6. package/dist/archive-read.js +9 -18
  7. package/dist/archive-zip-entry.d.ts +11 -11
  8. package/dist/archive-zip-entry.js +3 -35
  9. package/dist/archive-zip-integrity.d.ts +2 -2
  10. package/dist/archive-zip-integrity.js +2 -12
  11. package/dist/archive-zip-loader.d.ts +7 -3
  12. package/dist/archive-zip-loader.js +10 -9
  13. package/dist/archive-zip-preflight.d.ts +2 -1
  14. package/dist/archive-zip-preflight.js +16 -7
  15. package/dist/archive.js +17 -16
  16. package/dist/directory-receipt.js +5 -7
  17. package/dist/effective-uid.js +1 -4
  18. package/dist/errors.d.ts +3 -1
  19. package/dist/errors.js +3 -2
  20. package/dist/file-lock-sync-root-held.js +1 -4
  21. package/dist/file-store.d.ts +4 -7
  22. package/dist/json-document-store.d.ts +4 -9
  23. package/dist/local-file-access.js +2 -5
  24. package/dist/move-path-cleanup.js +4 -4
  25. package/dist/native-binding.d.ts +6 -1
  26. package/dist/native-staged-symlink.d.ts +13 -0
  27. package/dist/native-staged-symlink.js +303 -0
  28. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  29. package/dist/owner-dacl-batch-worker.js +54 -0
  30. package/dist/owner-dacl-batch.d.ts +5 -0
  31. package/dist/owner-dacl-batch.js +64 -0
  32. package/dist/owner-dacl.d.ts +2 -0
  33. package/dist/owner-dacl.js +3 -0
  34. package/dist/path.js +17 -1
  35. package/dist/permission-exec.js +3 -6
  36. package/dist/permissions-public.d.ts +1 -0
  37. package/dist/permissions-public.js +1 -0
  38. package/dist/pinned-mutation-admission.d.ts +0 -1
  39. package/dist/pinned-open.d.ts +0 -1
  40. package/dist/pinned-open.js +1 -2
  41. package/dist/publish-copy-stage.js +4 -0
  42. package/dist/read-opened-file.d.ts +2 -5
  43. package/dist/regular-file.js +3 -3
  44. package/dist/replace-file-copy-fallback.d.ts +1 -2
  45. package/dist/root-impl.js +0 -3
  46. package/dist/root-observed-path.d.ts +0 -1
  47. package/dist/root-observed-path.js +0 -3
  48. package/dist/root-path-observation.d.ts +4 -11
  49. package/dist/root-path.js +7 -10
  50. package/dist/root-write-admission.js +0 -2
  51. package/dist/safe-path-segment.d.ts +1 -0
  52. package/dist/safe-path-segment.js +8 -2
  53. package/dist/secure-file.js +3 -2
  54. package/dist/sidecar-lock.js +5 -3
  55. package/dist/staged-symlink-types.d.ts +49 -0
  56. package/dist/staged-symlink-types.js +1 -0
  57. package/dist/symlink-parents.js +58 -7
  58. package/dist/temp-target.js +4 -2
  59. package/dist/temp-workspace-owner.js +4 -9
  60. package/dist/text-atomic.d.ts +2 -1
  61. package/dist/text-atomic.js +2 -0
  62. package/dist/trash.js +27 -1
  63. package/dist/walk.d.ts +2 -5
  64. package/dist/windows-owner.d.ts +0 -1
  65. package/dist/windows-owner.js +0 -1
  66. package/dist/windows-security-bridge.cs +6 -4
  67. package/dist/windows-security-bridge.ps1 +78 -3
  68. package/dist/windows-security-command.d.ts +8 -0
  69. package/dist/windows-security-command.js +66 -12
  70. package/dist/windows-security-facts.d.ts +3 -0
  71. package/dist/windows-security-facts.js +4 -0
  72. package/docs/advanced.md +3 -2
  73. package/docs/archive.md +8 -0
  74. package/docs/atomic.md +11 -3
  75. package/docs/contributing.md +30 -0
  76. package/docs/install.md +28 -0
  77. package/docs/native-helper.md +5 -4
  78. package/docs/native.md +45 -1
  79. package/docs/permissions.md +66 -0
  80. package/docs/public-api.md +7 -1
  81. package/docs/security-model.md +4 -1
  82. package/docs/sidecar-lock.md +2 -0
  83. package/docs/staged-symlink.md +123 -0
  84. package/docs/store.md +3 -1
  85. package/docs/testing.md +28 -0
  86. package/docs/writing.md +10 -0
  87. package/package.json +9 -9
@@ -1,6 +1,6 @@
1
1
  param(
2
2
  [Parameter(Mandatory = $true)]
3
- [ValidateSet('path', 'descriptor', 'create', 'directory', 'protect-file', 'verify-file')]
3
+ [ValidateSet('path', 'paths', 'descriptor', 'create', 'directory', 'protect-file', 'verify-file')]
4
4
  [string] $Operation
5
5
  )
6
6
 
@@ -11,5 +11,80 @@ $env:PSModulePath = [IO.Path]::Combine($PSHOME, 'Modules')
11
11
  [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
12
12
 
13
13
  Microsoft.PowerShell.Utility\Add-Type -LiteralPath ([IO.Path]::Combine($PSScriptRoot, 'windows-security-bridge.cs'))
14
- $targetPath = [Environment]::GetEnvironmentVariable('FS_SAFE_WINDOWS_SECURITY_PATH')
15
- [FsSafeWindowsBridge]::Execute($Operation, $targetPath) | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 8 -Compress
14
+ if ($Operation -eq 'paths') {
15
+ $reply = $null
16
+ $limit = 16 * 1024 * 1024
17
+ try {
18
+ $inputStream = [Console]::OpenStandardInput()
19
+ $inputBytes = [IO.MemoryStream]::new()
20
+ try {
21
+ $buffer = [byte[]]::new(8192)
22
+ while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
23
+ if ($inputBytes.Length + $read -gt $limit) {
24
+ throw 'Windows security path batch exceeds the input budget'
25
+ }
26
+ $inputBytes.Write($buffer, 0, $read)
27
+ }
28
+ $inputJson = [Text.UTF8Encoding]::new($false, $true).GetString($inputBytes.ToArray())
29
+ } finally {
30
+ $inputBytes.Dispose()
31
+ $inputStream.Dispose()
32
+ }
33
+ if (-not $inputJson.TrimStart().StartsWith('[')) {
34
+ throw 'Windows security paths must be a JSON array'
35
+ }
36
+ # Validate the whole document before wrapping it; the wrapper keeps empty,
37
+ # singleton, and nested arrays intact on Windows PowerShell 5.1.
38
+ $null = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject $inputJson
39
+ $request = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject ('{"paths":' + $inputJson + '}')
40
+ if ($request.paths -isnot [array]) {
41
+ throw 'Windows security paths must be a JSON array'
42
+ }
43
+ for ($index = 0; $index -lt $request.paths.Length; $index++) {
44
+ $value = $request.paths[$index]
45
+ if ($value -isnot [string] -or $value.Length -eq 0 -or $value.IndexOf([char]0) -ge 0) {
46
+ throw 'Windows security paths must be nonempty strings without NUL bytes'
47
+ }
48
+ }
49
+ } catch {
50
+ $reply = @{ ok = $false; code = 'EINVAL'; message = 'Invalid Windows security path batch' }
51
+ }
52
+ if ($null -eq $reply) {
53
+ $utf8 = [Console]::OutputEncoding
54
+ $prefix = '{"ok":true,"result":['
55
+ $suffix = ']}'
56
+ $bytes = $utf8.GetByteCount($prefix) + $utf8.GetByteCount($suffix)
57
+ $encoded = [Text.StringBuilder]::new($prefix)
58
+ $separator = ''
59
+ foreach ($pathname in $request.paths) {
60
+ try {
61
+ $reply = [FsSafeWindowsBridge]::Execute('path', $pathname)
62
+ } catch {
63
+ $reply = @{ ok = $false; code = 'EINVAL'; message = 'Invalid Windows security path batch' }
64
+ }
65
+ if (-not $reply.ok) { break }
66
+ $rowJson = Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject ([ordered]@{ path = $pathname; security = $reply.result }) -Depth 8 -Compress
67
+ $reply = $null
68
+ $nextBytes = $bytes + $separator.Length + $utf8.GetByteCount($rowJson)
69
+ if ($nextBytes -gt $limit) {
70
+ $rowJson = $null
71
+ $reply = @{ ok = $false; code = 'too-large'; message = 'Windows security batch exceeded its output budget' }
72
+ break
73
+ }
74
+ [void]$encoded.Append($separator).Append($rowJson)
75
+ $bytes = $nextBytes
76
+ $separator = ','
77
+ $rowJson = $null
78
+ }
79
+ if ($null -eq $reply) {
80
+ [void]$encoded.Append($suffix)
81
+ [Console]::Write($encoded.ToString())
82
+ return
83
+ }
84
+ $encoded = $null
85
+ }
86
+ } else {
87
+ $targetPath = [Environment]::GetEnvironmentVariable('FS_SAFE_WINDOWS_SECURITY_PATH')
88
+ $reply = [FsSafeWindowsBridge]::Execute($Operation, $targetPath)
89
+ }
90
+ $reply | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 8 -Compress
@@ -1,6 +1,14 @@
1
1
  import type { NativeWindowsDescriptorSecurityFacts, NativeWindowsSecurityFacts } from "./native-binding.js";
2
+ import type { FsSafeNativeMode } from "./native-config.js";
3
+ import { type DescriptorFacts } from "./windows-security-facts.js";
4
+ export declare const WINDOWS_SECURITY_BATCH_MAX_BYTES: number;
2
5
  /** Cleanup must preserve stages still reachable by an unsettled command. */
3
6
  export declare function hasUnsettledWindowsSecurityCommand(error: unknown): boolean;
7
+ export declare function readWindowsSecurityFactsBatch(paths: readonly string[], options: {
8
+ timeoutMs: number;
9
+ native: boolean;
10
+ mode: FsSafeNativeMode;
11
+ }): Promise<DescriptorFacts[]>;
4
12
  export declare function readWindowsSecurityFactsCommand(targetPath: string): NativeWindowsSecurityFacts;
5
13
  export declare function inspectWindowsDescriptorCommand(fd: number): Promise<NativeWindowsDescriptorSecurityFacts>;
6
14
  export declare function createPrivateWindowsDirectoryCommand(targetPath: string, expectedParentIdentity?: string): Promise<{
@@ -3,10 +3,11 @@ import { fileURLToPath } from "node:url";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, PermissionCommandError } from "./permission-exec.js";
5
5
  import { resolveWindowsSystemCommand } from "./windows-command.js";
6
- import { parseWindowsSecurityCommandFacts, unverified } from "./windows-security-facts.js";
6
+ import { parseWindowsOwnerAndDaclFacts, parseWindowsSecurityCommandFacts, unverified } from "./windows-security-facts.js";
7
7
  const MAX_OUTPUT_BYTES = 1024 * 1024;
8
8
  const TERMINATION_GRACE_MS = 1_000;
9
9
  const FULL_IDENTITY = /^[0-9a-f]{16}:[0-9a-f]{32}$/;
10
+ export const WINDOWS_SECURITY_BATCH_MAX_BYTES = 16 * 1024 * 1024;
10
11
  function isFullIdentity(identity) {
11
12
  return typeof identity === "string" && identity.length === 49 && FULL_IDENTITY.test(identity);
12
13
  }
@@ -76,6 +77,10 @@ function parseReplyValue(stdout, operation) {
76
77
  unverified("Windows security command returned an incomplete response");
77
78
  }
78
79
  if (!response.ok) {
80
+ if (operation === "paths" &&
81
+ (response.code === "helper-unavailable" || response.code === "too-large") && typeof response.message === "string") {
82
+ throw new FsSafeError(response.code, response.message);
83
+ }
79
84
  const codes = new Set(["EACCES", "EPERM", "EEXIST", "ENOENT", "ENOTSUP", "EIO", "EBADF", "ELOOP", "ENOTDIR", "EINVAL", "ENOSPC", "EBUSY"]);
80
85
  if (typeof response.code !== "string" || !codes.has(response.code) || typeof response.message !== "string") {
81
86
  unverified("Windows security command returned an invalid failure");
@@ -112,13 +117,13 @@ class WindowsSecurityCommandError extends PermissionCommandError {
112
117
  timedOut;
113
118
  creationOutcome;
114
119
  processExitConfirmed;
115
- constructor(file, durationMs, receipt, timedOut) {
116
- super(file, durationMs, receipt);
120
+ constructor(file, durationMs, receipt, timedOut, timeoutMs = DEFAULT_PERMISSION_EXEC_TIMEOUT_MS) {
121
+ super(file, durationMs, receipt, timeoutMs);
117
122
  this.timedOut = timedOut;
118
123
  this.creationOutcome = receipt.creationOutcome;
119
124
  this.processExitConfirmed = receipt.processExitConfirmed;
120
125
  if (timedOut)
121
- this.message = `Windows permission inspection timed out after ${DEFAULT_PERMISSION_EXEC_TIMEOUT_MS}ms`;
126
+ this.message = `Windows permission inspection timed out after ${timeoutMs}ms`;
122
127
  if (!receipt.processExitConfirmed)
123
128
  this.message += "; process exit was not confirmed";
124
129
  else if (!receipt.outputClosed)
@@ -156,11 +161,12 @@ export function hasUnsettledWindowsSecurityCommand(error) {
156
161
  return pending.length > 0;
157
162
  }
158
163
  function ignoreLateError() { }
159
- async function execute(operation, params) {
160
- const { file, args, env } = command(operation, params);
164
+ async function execute(operation, params, request) {
165
+ const selected = request ?? command(operation, params);
166
+ const { file, args, env, input, timeoutMs = DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, maxOutputBytes = MAX_OUTPUT_BYTES } = selected;
161
167
  const startedAt = performance.now();
162
168
  return await new Promise((resolve, reject) => {
163
- const child = spawn(file, args, { windowsHide: true, env, stdio: [params.fd ?? "ignore", "pipe", "pipe"] });
169
+ const child = spawn(file, args, { windowsHide: true, env, stdio: [input === undefined ? params.fd ?? "ignore" : "pipe", "pipe", "pipe"] });
164
170
  const output = [];
165
171
  const errors = [];
166
172
  let bytes = 0;
@@ -180,7 +186,7 @@ async function execute(operation, params) {
180
186
  const timeout = setTimeout(() => {
181
187
  timedOut = true;
182
188
  fail(deadlineError());
183
- }, DEFAULT_PERMISSION_EXEC_TIMEOUT_MS);
189
+ }, timeoutMs);
184
190
  const finish = (outputClosed) => {
185
191
  if (settled)
186
192
  return;
@@ -193,6 +199,18 @@ async function execute(operation, params) {
193
199
  child.removeListener("error", onChildError);
194
200
  child.on("error", ignoreLateError);
195
201
  const cleanupErrors = [];
202
+ if (input !== undefined && child.stdin) {
203
+ child.stdin.removeListener("error", fail);
204
+ child.stdin.on("error", ignoreLateError);
205
+ if (!outputClosed) {
206
+ try {
207
+ child.stdin.destroy();
208
+ }
209
+ catch (error) {
210
+ cleanupErrors.push(error);
211
+ }
212
+ }
213
+ }
196
214
  for (const [stream, collect] of [[child.stdout, collectOutput], [child.stderr, collectErrors]]) {
197
215
  if (!stream)
198
216
  continue;
@@ -221,7 +239,7 @@ async function execute(operation, params) {
221
239
  cause: failure, pid: child.pid ?? null, code: exitCode, signal: exitSignal, stderr: Buffer.concat(errors),
222
240
  processExitConfirmed, outputClosed, terminationSignalSent, terminationError, cleanupErrors,
223
241
  ...(operation === "create" ? { creationOutcome: "unconfirmed" } : {}),
224
- }, timedOut));
242
+ }, timedOut, timeoutMs));
225
243
  }
226
244
  else {
227
245
  try {
@@ -260,7 +278,7 @@ async function execute(operation, params) {
260
278
  if (failed || settled)
261
279
  return;
262
280
  bytes += chunk.length;
263
- if (bytes <= MAX_OUTPUT_BYTES)
281
+ if (bytes <= maxOutputBytes)
264
282
  chunks.push(chunk);
265
283
  else
266
284
  fail(new Error("Windows security command exceeded its output budget"));
@@ -282,7 +300,7 @@ async function execute(operation, params) {
282
300
  };
283
301
  const onClose = (code, signal) => {
284
302
  onExit(code, signal);
285
- if (!failed && performance.now() - startedAt >= DEFAULT_PERMISSION_EXEC_TIMEOUT_MS) {
303
+ if (!failed && performance.now() - startedAt >= timeoutMs) {
286
304
  timedOut = true;
287
305
  failed = true;
288
306
  failure = deadlineError();
@@ -298,11 +316,47 @@ async function execute(operation, params) {
298
316
  child.stderr?.on("error", fail);
299
317
  child.stdout?.on("data", collectOutput);
300
318
  child.stderr?.on("data", collectErrors);
301
- if (!child.stdout || !child.stderr) {
319
+ if (!child.stdout || !child.stderr || (input !== undefined && !child.stdin)) {
302
320
  // Node reports spawn errors on nextTick, which can follow the current microtask.
303
321
  missingPipes = setImmediate(() => { if (!failed && !settled)
304
322
  fail(new Error("Windows security command output pipes are unavailable")); });
305
323
  }
324
+ if (input !== undefined && child.stdin) {
325
+ child.stdin.on("error", fail);
326
+ try {
327
+ child.stdin.end(input, "utf8");
328
+ }
329
+ catch (error) {
330
+ fail(error);
331
+ }
332
+ }
333
+ });
334
+ }
335
+ export async function readWindowsSecurityFactsBatch(paths, options) {
336
+ const invocation = command("paths", {});
337
+ const request = {
338
+ ...invocation,
339
+ input: JSON.stringify(paths),
340
+ timeoutMs: options.timeoutMs,
341
+ maxOutputBytes: WINDOWS_SECURITY_BATCH_MAX_BYTES,
342
+ };
343
+ if (options.native) {
344
+ request.file = process.execPath;
345
+ request.args = ["--", fileURLToPath(new URL("./owner-dacl-batch-worker.js", import.meta.url))];
346
+ request.env = {
347
+ ...Object.fromEntries(Object.entries(invocation.env).filter(([key]) => !["NODE_OPTIONS", "NODE_PATH", "FS_SAFE_OWNER_DACL_BATCH_MODE"].includes(key.toUpperCase()))),
348
+ FS_SAFE_OWNER_DACL_BATCH_MODE: options.mode,
349
+ };
350
+ }
351
+ const response = await execute("paths", {}, request);
352
+ if (!Array.isArray(response) || response.length !== paths.length) {
353
+ unverified("Windows security command returned an incomplete path batch");
354
+ }
355
+ return response.map((row, index) => {
356
+ if (!record(row) || row.path !== paths[index]) {
357
+ unverified("Windows security command returned a mismatched path batch");
358
+ }
359
+ return parseWindowsOwnerAndDaclFacts(row.security);
306
360
  });
307
361
  }
308
362
  export function readWindowsSecurityFactsCommand(targetPath) {
@@ -1,5 +1,8 @@
1
1
  import type { NativeWindowsSecurityFacts } from "./native-binding.js";
2
+ export type DescriptorFacts = Pick<NativeWindowsSecurityFacts, "ownerSid" | "currentUserSid" | "daclPresent" | "isLocal" | "aceListComplete" | "unsupportedAceTypes" | "aces">;
2
3
  export declare function unverified(message: string, cause?: unknown): never;
4
+ /** Validate policy-free observations crossing the isolated batch transport. */
5
+ export declare function parseWindowsOwnerAndDaclFacts(value: unknown): DescriptorFacts;
3
6
  /** Raw reporting retains unknown flag bits; secure admission validates them below. */
4
7
  export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
5
8
  /** Native and command observations share the same fail-closed admission policy. */
@@ -46,6 +46,10 @@ function validateDescriptor(value, allowUnknownFlags) {
46
46
  }
47
47
  return value;
48
48
  }
49
+ /** Validate policy-free observations crossing the isolated batch transport. */
50
+ export function parseWindowsOwnerAndDaclFacts(value) {
51
+ return validateDescriptor(value, true);
52
+ }
49
53
  function summarize(facts) {
50
54
  const ownerClass = facts.ownerSid === facts.currentUserSid ? "current-user"
51
55
  : facts.ownerSid === SYSTEM_SID ? "system"
package/docs/advanced.md CHANGED
@@ -80,7 +80,7 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
80
80
  | `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
81
81
  | `readDirectoryIdentity`, `assertDirectoryIdentitySync`, `DirectoryIdentity` | [directory-identity.md](directory-identity.md) | Observe exact bigint directory identity and synchronously check a caller-selected path, optionally retaining its canonical path. |
82
82
  | `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
83
- | `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks. |
83
+ | `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks, inspecting raw segments before `..` normalization. A `..` may undo an inspected real directory, but cannot leave the root or undo an allowed root-child symlink. Raw paths outside the root that normalize inside are rejected. |
84
84
  | `assertNoHardlinkedFinalPath`, `assertNoPathAliasEscape`, `PATH_ALIAS_POLICIES`, `PathAliasPolicy` | – | Hardlink/alias defense building blocks. |
85
85
 
86
86
  `pathExists()` and `pathExistsSync()` intentionally retain ordinary `stat`
@@ -240,6 +240,7 @@ atomic replacement, use [`Root.write()`](writing.md).
240
240
  | Export | Page | Notes |
241
241
  |---|---|---|
242
242
  | `stageFileInDirectory`, `StagedFile`, `StagedFileReceipt`, `PublishedFileReceipt`, `StagedFilePublication`, `StagedFileCleanupReceipt`, `StagedFileFailureDetails` | [staged-file.md](staged-file.md) | Native-required Linux/macOS lifecycle retaining the original directory for abort cleanup. |
243
+ | `retainSymlinkInDirectory`, `StagedSymlink`, `StagedSymlinkExpected`, `StagedSymlinkReceipt`, `PublishedSymlinkReceipt`, `StagedSymlinkPublication`, `StagedSymlinkRemoval`, `StagedSymlinkCleanupReceipt`, `StagedSymlinkFailureDetails` | [staged-symlink.md](staged-symlink.md) | Native-required retained symlink identity, no-replace publication and explicit recovery; never same-target ownership adoption. |
243
244
  | `tempFile`, `withTempFile`, `TempFile`, `buildRandomTempFilePath`, `sanitizeTempFileName` | [temp.md](temp.md) | One-file temp primitive; prefer `tempWorkspace` from `@openclaw/fs-safe/temp` for the stable surface. |
244
245
  | `writeSiblingTempFile`, `writeViaSiblingTempPath`, `WriteSiblingTempFileOptions`, `WriteSiblingTempFileResult` | – | Callback-produced file staging: verified sibling publication or private-workspace copy through a root. |
245
246
 
@@ -256,7 +257,7 @@ atomic replacement, use [`Root.write()`](writing.md).
256
257
  |---|---|---|
257
258
  | `createAsyncLock` | – | In-process async lock (separate from cross-process file locks). |
258
259
  | `withTimeout` | [timing.md](timing.md) | Wrap a promise with a timeout that raises `Error` by default, or an error supplied by `createError`. |
259
- | `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. |
260
+ | `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. Allowed roots constrain the real parent of the moved entry, including symlinks; the referent is not moved. Parent identity is rechecked before mutation. |
260
261
 
261
262
  ## Stability
262
263
 
package/docs/archive.md CHANGED
@@ -211,6 +211,14 @@ loading, including failure. Public preflight still returns ordinary JSZip entry
211
211
  objects, with directory keys ending in `/` and recognizable symlink type bits.
212
212
  Compressed data is retained even for declared-zero entries, so an empty-size
213
213
  claim cannot bypass payload-size or CRC verification during extraction or reads.
214
+ Extraction and bounded reads retain the admitted CRC and size independently of
215
+ mutable decoder objects, so later decoder changes cannot redefine the expected
216
+ payload integrity. Public preflight archives retain ordinary JSZip mutation
217
+ and entry-reading behavior.
218
+ Portable bounded reads select the admitted canonical name and reject a selected
219
+ decoder entry that no longer belongs to that archive and name before reading its
220
+ payload. Extraction retains the admitted names and physical order independently
221
+ of later changes to the decoder's public `files` object.
214
222
 
215
223
  Within one ZIP entry, identical local and central name bytes reuse the same
216
224
  decoded validation. Unicode Path admission is shared only when both the raw names
package/docs/atomic.md CHANGED
@@ -295,9 +295,9 @@ semantics. For single-file replacement, `replaceFileAtomic` is the right tool.
295
295
 
296
296
  Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
297
297
  temp file, descriptor-bound mode setting and fsync, rename, and parent fsync.
298
- It delegates to `replaceFileAtomic()` with a smaller call shape. Use it when
299
- you do not need replacement hooks such as `beforeRename`, `preserveExistingMode`,
300
- or custom copy-fallback policy.
298
+ It delegates to `replaceFileAtomic()` with a smaller call shape, including its
299
+ pre-publication hook and staging-prefix options. Use `replaceFileAtomic()` when
300
+ you need mode preservation or a custom copy-fallback policy.
301
301
 
302
302
  ```ts
303
303
  import { writeTextAtomic } from "@openclaw/fs-safe/atomic";
@@ -317,9 +317,17 @@ type WriteTextAtomicOptions = {
317
317
  dirMode?: number; // parent mode (default 0o777 masked by process umask)
318
318
  trailingNewline?: boolean; // append "\n" if missing; default false
319
319
  durable?: boolean; // default true; false skips temp/parent fsync
320
+ beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
321
+ tempPrefix?: string; // default ".fs-safe-replace"
320
322
  };
321
323
  ```
322
324
 
325
+ `beforeRename` is awaited after the complete text is staged and before
326
+ publication, with the same [stage identity and refusal cleanup](#beforerename)
327
+ checks as `replaceFileAtomic`. Pass `tempPrefix` to identify staged files; it
328
+ uses the same prefix validation, including rejection of empty prefixes and path
329
+ separators.
330
+
323
331
  `durable: false` keeps the sibling-temp replace/rename behavior but skips the
324
332
  temp-file and parent-directory `fsync` calls. Use it only for reconstructible
325
333
  metadata where lower latency matters more than crash-durability.
@@ -70,6 +70,36 @@ Consumers receive the asset in the npm package and need no compiler.
70
70
 
71
71
  Output lands in `dist/`. The package's `prepack` hook re-runs the build before publishing — manual `pnpm build` is only required when you want to inspect the output or run a freshly-built copy locally.
72
72
 
73
+ ### Linux GNU release bindings
74
+
75
+ GNU x64 and arm64 artifacts use Zig 0.16.0 and `cargo-zigbuild` 0.23.4 with an
76
+ explicit glibc 2.28 target, independent of the runner's libc. This matches the
77
+ Node Linux runtime baseline and supports RHEL 8-family users without adding a
78
+ second legacy package. Run the same build and ABI gate used in CI and releases:
79
+
80
+ ```bash
81
+ cargo install cargo-zigbuild --version 0.23.4 --locked
82
+ rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
83
+ node scripts/build-linux-gnu.mjs x86_64-unknown-linux-gnu
84
+ node scripts/build-linux-gnu.mjs aarch64-unknown-linux-gnu
85
+ ```
86
+
87
+ These commands require Zig on `PATH` and GNU `objdump`. They build the N-API
88
+ cdylib with `cargo zigbuild --target <triple>.2.28`, copy it to the existing
89
+ `artifacts/fs-safe-native.<platform>.node` name, and reject any GLIBC symbol
90
+ requirement above 2.28 before upload. The gate also rejects missing or unknown
91
+ GLIBC versions. To inspect an existing binding, use
92
+ `node scripts/check-linux-glibc.mjs <binding.node>`.
93
+
94
+ `pnpm native:build` remains a host-toolchain development build; it does not
95
+ establish the GNU release ABI floor.
96
+
97
+ On Linux x64 with Docker, run `pnpm build`, copy the GNU x64 artifact from
98
+ `artifacts/` to `native/`, run `node scripts/stage-host-native.mjs`, then run
99
+ `bash scripts/test-linux-glibc-floor.sh`. CI uses this command to load the actual
100
+ artifact and run native security and no-replace move tests in Rocky Linux 8
101
+ (glibc 2.28). GNU arm64 is cross-built and symbol-checked in the same CI matrix.
102
+
73
103
  ## Test
74
104
 
75
105
  ```bash
package/docs/install.md CHANGED
@@ -134,6 +134,16 @@ when the matching package is absent, incompatible, or disabled.
134
134
  Upgrading an existing 0.5 consumer? Follow [Migrating to 0.6](migrating-to-0.6.md)
135
135
  before deploying with native mode `require` or native-only features.
136
136
 
137
+ ### Older Linux kernels and seccomp
138
+
139
+ The native addon supports kernels without `openat2` (before Linux 5.6) and
140
+ containers returning `ENOSYS` or probe-time `EPERM` for that syscall. Beneath
141
+ opens use a guarded no-follow component walk and report `best-effort`.
142
+ Nested no-clobber moves retain atomic `renameat2(RENAME_NOREPLACE)` and exact
143
+ identity checks; keep native mode enabled. Strict bounded cleanup still
144
+ requires `openat2` with `RESOLVE_NO_XDEV` and reports `helper-unavailable`
145
+ without it. See [Linux capability behavior and limits](native.md#linux-without-openat2).
146
+
137
147
  ### Windows security fallback
138
148
 
139
149
  Windows raw owner/DACL inspection, private-directory creation, and secure-file
@@ -161,6 +171,24 @@ available native operation's error never triggers a command retry. See
161
171
 
162
172
  ## Native helper policy
163
173
 
174
+ ### Supported native platforms
175
+
176
+ Prebuilt bindings cover Linux x64/arm64 (GNU glibc **2.28 or newer**, or musl),
177
+ macOS x64/arm64, and Windows x64. The GNU baseline includes RHEL 8, Rocky Linux 8,
178
+ and AlmaLinux 8. A compatible Node 22+ runtime and the kernel/filesystem features
179
+ required by each operation are still necessary; a loadable addon alone does not
180
+ guarantee every native capability. Systems older than glibc 2.28 are outside the
181
+ GNU binary support floor.
182
+
183
+ A missing or incompatible optional binding (including `ERR_DLOPEN_FAILED` from
184
+ glibc) does not prevent importing fs-safe. In `auto`, supported JavaScript/WASM
185
+ fallbacks remain available. Native-only operations such as the default
186
+ no-clobber `Root.move()` still fail closed with `helper-unavailable`; `require`
187
+ also rejects fallback-capable operations and retains the binding load error as
188
+ the cause.
189
+
190
+ ### Loading modes
191
+
164
192
  The platform native binaries provide fd-relative open/link/mkdir primitives,
165
193
  atomic no-replace rename, and file identity checks. The default is `auto`: use
166
194
  the matching binary when it loads, otherwise use the guarded JavaScript path
@@ -103,7 +103,7 @@ clone/copy/hash workers, POSIX canonicalization, and Windows security descriptor
103
103
  layer owns policy, retries, filters, budgets, modes, cleanup, error
104
104
  normalization, and the decision to fall back.
105
105
 
106
- - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Direct-child no-replace renames borrow already-retained parent descriptors; deeper relative paths retain the guarded reopen. Owned-tree cleanup enumerates and unlinks through retained directory descriptors and rejects device crossings.
106
+ - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Without `openat2`, beneath opens use the [no-follow component walk](native.md#linux-without-openat2) with exact identity checks and report `best-effort`. Direct-child no-replace renames borrow already-retained parent descriptors; deeper relative paths retain the guarded reopen. Owned-tree cleanup still requires `openat2` with `RESOLVE_NO_XDEV` and fails closed when unavailable.
107
107
  - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Direct-child no-replace renames borrow already-retained parents without another `F_GETPATH`; deeper paths retain the guarded reopen. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
108
108
  - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer. The dedicated retained-directory `renameNoReplaceWithIdentity` primitive compares its required exact source receipt with the handle opened for rename before mutation. Its internal mismatch status is normalized to public `path-mismatch`; the legacy four-argument `renameNoReplace` export and its other callers are unchanged. Owned trees are deleted through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table. Descriptor-producing operations also require that same host's synchronous libuv close and request-management APIs before exporting an owned descriptor.
109
109
 
@@ -139,11 +139,12 @@ for the exact difference.
139
139
  The guarded JavaScript mutation path is detection-based, not containment-atomic.
140
140
  If a same-privilege peer can replace a writable parent after its identity guard
141
141
  but before Node resolves a pathname mutation, the mutation can land outside the
142
- intended root before the post-operation guard throws. Select `require` rather
143
- than `auto` or `off` when that concurrent attacker is part of the threat model.
142
+ intended root before the post-operation guard throws. Native `require` ensures the addon is present, but does not require a
143
+ `kernel-atomic` resolver; inspect containment and use OS isolation when that
144
+ concurrent attacker is part of the threat model.
144
145
 
145
146
  `openBeneath()` returns `{ fd, containment }`. `containment` is
146
- `"kernel-atomic"` for Linux `openat2` and `"best-effort"` for macOS and
147
+ `"kernel-atomic"` for Linux `openat2` and `"best-effort"` for the Linux fallback, macOS and
147
148
  Windows. Public JavaScript root open/read/writable results also expose the
148
149
  field and report `"best-effort"`; the label reports mechanism, not policy.
149
150
 
package/docs/native.md CHANGED
@@ -43,6 +43,7 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
43
43
 
44
44
  - Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative
45
45
  `mkdirat`/`linkat`/`renameat`/`renameat2`, `FICLONE`, and `copy_file_range`.
46
+ Without `openat2`, beneath opens use the [guarded fallback](#linux-without-openat2).
46
47
  - macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk
47
48
  components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the
48
49
  pinned root. Both routes apply an `F_GETPATH` post-open containment detector,
@@ -85,6 +86,49 @@ privately owned empty ACL. Unsupported, malformed, and failed inspection is not
85
86
  reported as absence. These facts do not classify individual ACE permissions or
86
87
  prove volume ownership enforcement; each caller applies its own security policy.
87
88
 
89
+ ## Linux without openat2
90
+
91
+ Linux kernels before 5.6 and containers whose seccomp policy denies `openat2`
92
+ can keep native mode `auto` or `require`. The addon caches one harmless
93
+ `openat2(".")` capability probe per process. `ENOSYS`, or `EPERM` on that probe,
94
+ selects the native `openat` fallback. An `EPERM`/`EACCES` from an application
95
+ operation is still a permission error and never triggers a retry. Install
96
+ syscall filters before the first native operation; a later `ENOSYS` fails with
97
+ `ENOTSUP`, rather than changing the cached mechanism during a call.
98
+
99
+ The fallback opens each directory relative to a retained descriptor with
100
+ `O_PATH | O_DIRECTORY | O_NOFOLLOW`, compares exact device/inode/type identities,
101
+ and rechecks the retained parent chain before and after the final no-follow
102
+ open. It rejects all symlink components, including procfs magic links, even
103
+ when the low-level caller requests symlink following. Public Root policy and
104
+ canonical-path admission, hardlink rejection, pinned-file checks, and mutation
105
+ identity fences remain in place. `openBeneath()` reports `best-effort`: these
106
+ identity samples detect replacements but cannot make a multi-component walk
107
+ atomic against a hostile process renaming directories between samples. This
108
+ is the same documented containment class as the macOS and JavaScript paths;
109
+ applications requiring atomic beneath resolution must check the result or use
110
+ OS isolation. Rejection after a mutating open does not promise rollback.
111
+
112
+ Nested no-clobber `Root.move()` still admits both parents and uses
113
+ `renameat2(RENAME_NOREPLACE)`. Existing destinations are never overwritten.
114
+ That separate syscall/filesystem capability remains required; if unavailable,
115
+ the operation fails with `helper-unavailable`. Turning native mode `off` still
116
+ disables no-clobber moves because Node has no equivalent atomic rename API.
117
+
118
+ Bounded owned-tree cleanup deliberately has no `openat` fallback:
119
+ `RESOLVE_NO_XDEV` rejects bind mounts even when device numbers match, which
120
+ ordinary identity checks cannot reproduce. `cleanupSafety: "require-bounded"`
121
+ fails before workspace creation with `helper-unavailable`; low-level cleanup
122
+ opens report `ENOTSUP`. Compatible cleanup retains its documented behavior.
123
+ Low-level `O_TMPFILE` anonymous opens also fail before creation with `ENOTSUP`
124
+ in the fallback, because named-entry identity checks cannot verify an unnamed
125
+ file. Public staged-write APIs use exclusive named files and remain available.
126
+
127
+ For tests, set `FS_SAFE_TEST_NO_OPENAT2=1` before starting Node to force the
128
+ fallback (including bounded-cleanup refusal). It is read only at the first
129
+ capability probe. It has no effect on macOS or Windows.
130
+ See [Linux fallback testing](testing.md#linux-openat2-fallback).
131
+
88
132
  ## Archives
89
133
 
90
134
  Native ZIP and TAR entry reads retain a private, unpooled input Buffer in
@@ -252,7 +296,7 @@ See [Root containment guarantees](security-model.md#containment-guarantees-by-pl
252
296
 
253
297
  | Capability | Native path | Guarded JavaScript path |
254
298
  |---|---|---|
255
- | Native beneath opens and Root mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. No-clobber `Root.move()` admits both parents and uses the native no-replace rename. Native `openBeneath()` reports `kernel-atomic` on Linux and `best-effort` on macOS and Windows. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. No-clobber `Root.move()` is unsupported because a check followed by a replacing rename is unsafe. A same-privilege peer can replace a writable parent after a guard assertion but before Node resolves another pathname mutation; the mutation may land outside the intended root before the post-check detects it. |
299
+ | Native beneath opens and Root mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. No-clobber `Root.move()` admits both parents and uses the native no-replace rename. Native `openBeneath()` reports `kernel-atomic` with Linux `openat2` and `best-effort` with the guarded Linux fallback, macOS, and Windows. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. No-clobber `Root.move()` is unsupported because a check followed by a replacing rename is unsafe. A same-privilege peer can replace a writable parent after a guard assertion but before Node resolves another pathname mutation; the mutation may land outside the intended root before the post-check detects it. |
256
300
  | ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | Optional JSZip or bundled WASM TAR into guarded private staging, then the same guarded merge policy. |
257
301
  | Zstd/bzip2 TAR | Rust streaming decode and fd-relative output creation. | Bundled WASM codecs feed the shared Rust TAR parser, then guarded private staging and the same merge policy; no optional codec dependency. |
258
302
  | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
@@ -185,6 +185,72 @@ the same raw ACE projection. Native `require` rejects either absence with
185
185
  query's failure is terminal. The existing coarse `inspectPathPermissions()` API
186
186
  still owns its compatibility fallback and trust classification.
187
187
 
188
+ ### Asynchronous batches
189
+
190
+ Use `readOwnerAndDaclBatch()` when several paths, such as a directory and its
191
+ ancestors, need inspection without blocking the caller's event loop:
192
+
193
+ ```ts
194
+ import { readOwnerAndDaclBatch } from "@openclaw/fs-safe/permissions";
195
+
196
+ const facts = await readOwnerAndDaclBatch(stagingDirectories, { timeoutMs: 60_000 });
197
+ ```
198
+
199
+ ```ts
200
+ function readOwnerAndDaclBatch(
201
+ paths: readonly string[],
202
+ options?: { timeoutMs?: number },
203
+ ): Promise<OwnerAndDaclResult[]>;
204
+ ```
205
+
206
+ Results use the same raw fact shape and SID spelling as `readOwnerAndDacl()`.
207
+ They correspond one-for-one to input order, including duplicate paths. An empty
208
+ array returns `[]` without dispatch. Other platforms return an
209
+ `unsupported-platform` result for each path. Query failures, malformed facts,
210
+ or missing/reordered response rows reject the entire batch; no partial facts
211
+ are returned. Null DACLs, incomplete ACE lists and nonlocal observations remain
212
+ raw facts for the caller's policy to evaluate.
213
+
214
+ The call captures paths, options and native mode before awaiting. Windows
215
+ relative paths are anchored to the current directory or selected drive at
216
+ entry, without normalizing their remaining `.` or `..` components. Empty,
217
+ nonstring, sparse or NUL-containing path entries and Windows namespace aliases
218
+ reject before dispatch. The UTF-8 JSON input is limited to 16 MiB.
219
+
220
+ An available native capability runs all queries in one isolated process using
221
+ the current runtime executable. Its resolved native mode is forwarded explicitly;
222
+ Node preload and module-search environment overrides are not inherited. An
223
+ available native query's failure is terminal. In `auto` when the binding or
224
+ capability is absent, or in `off`, one packaged PowerShell process reads the
225
+ path array from stdin and compiles the existing C# bridge once. Native `require`
226
+ rejects missing support before launching a process. No route launches one
227
+ process per path or wraps synchronous parent-process queries in promises.
228
+
229
+ `timeoutMs` defaults to 60,000 and applies to the whole process, including
230
+ startup and fallback compilation. It must be an integer from 1 through
231
+ 2,147,483,647. Combined stdout and stderr are limited to 16 MiB. Success waits
232
+ for process exit and both output pipes to close. Timeout and transport failure
233
+ request termination, then retain the existing one-second settlement grace.
234
+ The error's `processExitConfirmed` field, also retained in its cause receipt,
235
+ distinguishes observed exit from an unconfirmed termination attempt. An
236
+ unconfirmed child can still be reading after rejection; the operation returns
237
+ no facts and owns no output files or artifact cleanup. Neither timing out nor
238
+ receiving a successful kill request is reported as confirmed exit.
239
+
240
+ The PowerShell fallback encodes and budgets each result while collecting it,
241
+ instead of retaining every descriptor graph until the entire batch finishes.
242
+ An encoded response that would exceed 16 MiB rejects with `too-large` before
243
+ inspecting later paths. A query failure observed earlier retains its original
244
+ error, and no partial facts are returned. Duplicate paths remain independent
245
+ observations. This bounds accumulated encoded results, not total process memory:
246
+ the current descriptor and row, decoded input, and runtime overhead still exist.
247
+
248
+ Batch observations are point-in-time pathname facts, not a snapshot or a
249
+ retained filesystem capability. The caller still owns ancestry trust, principal
250
+ policy and authorization for subsequent operations. Existing singular queries
251
+ and other command operations retain their 30-second deadline, 1 MiB output
252
+ budget and documented failure behavior.
253
+
188
254
  ## Private directories
189
255
 
190
256
  ```ts
@@ -41,6 +41,11 @@ The lexical path surface additionally exports `isNodeError`,
41
41
  `UnsafeDeviceReadPathMatch`, `UnsafeDeviceReadPathOptions`, and
42
42
  `UnsafeDeviceReadPathReason`.
43
43
 
44
+ `isPathRelativeEscape()` rejects absolute paths and relative paths that step
45
+ above their starting directory at any point. Contained paths such as
46
+ `dir/../file` remain relative. It accepts both separators on Windows; on POSIX,
47
+ a backslash remains a literal filename character.
48
+
44
49
  The advanced root-file primitive exports `OpenRootFileParams`,
45
50
  `OpenRootFileSyncParams`, `RootFileOpenResult`, and
46
51
  `RootFileOpenFailureReason`. These are composition types for callers building
@@ -100,7 +105,8 @@ best-effort cleanup helper used by those queue flows.
100
105
  Permission inspection exposes `PermissionCheckOptions` and `SafeStatResult`.
101
106
  Private-directory creation uses `CreatePrivateDirectoryOptions`. Raw Windows
102
107
  descriptor facts use `OwnerAndDaclResult`, `WindowsAccessControlEntry`, and
103
- `WindowsAceFlags`.
108
+ `WindowsAceFlags`. `readOwnerAndDaclBatch()` returns those same facts in input
109
+ order through an isolated asynchronous batch with a whole-process timeout.
104
110
 
105
111
  Secure reads split their option and result shapes into
106
112
  `SecureFileTrustOptions`, `SecureFilePermissionOptions`,