@openclaw/fs-safe 0.18.2 → 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 (124) hide show
  1. package/CHANGELOG.md +47 -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-durability.js +1 -1
  6. package/dist/archive-merge.js +1 -1
  7. package/dist/archive-plan.d.ts +2 -7
  8. package/dist/archive-read.js +9 -18
  9. package/dist/archive-staging.js +3 -1
  10. package/dist/archive-zip-entry.d.ts +11 -11
  11. package/dist/archive-zip-entry.js +3 -35
  12. package/dist/archive-zip-integrity.d.ts +2 -2
  13. package/dist/archive-zip-integrity.js +2 -12
  14. package/dist/archive-zip-loader.d.ts +7 -3
  15. package/dist/archive-zip-loader.js +10 -9
  16. package/dist/archive-zip-preflight.d.ts +2 -1
  17. package/dist/archive-zip-preflight.js +16 -7
  18. package/dist/archive.js +17 -16
  19. package/dist/directory-receipt.js +5 -7
  20. package/dist/effective-uid.js +1 -4
  21. package/dist/errors.d.ts +3 -1
  22. package/dist/errors.js +3 -2
  23. package/dist/file-lock-sync-root-held.d.ts +4 -11
  24. package/dist/file-lock-sync-root-held.js +1 -4
  25. package/dist/file-lock-sync-root-io.d.ts +1 -4
  26. package/dist/file-lock-sync-root.d.ts +2 -4
  27. package/dist/file-store-boundary.d.ts +3 -7
  28. package/dist/file-store-boundary.js +7 -10
  29. package/dist/file-store-prune.js +3 -2
  30. package/dist/file-store.d.ts +4 -7
  31. package/dist/file-store.js +19 -21
  32. package/dist/guest-native-python.js +23 -31
  33. package/dist/guest.js +21 -12
  34. package/dist/json-document-store.d.ts +4 -9
  35. package/dist/json-durable-queue.js +2 -6
  36. package/dist/local-file-access.js +2 -5
  37. package/dist/local-file-descriptor.d.ts +2 -5
  38. package/dist/local-roots.d.ts +2 -7
  39. package/dist/move-path-cleanup.js +4 -4
  40. package/dist/native-binding.d.ts +18 -14
  41. package/dist/native-staged-symlink.d.ts +13 -0
  42. package/dist/native-staged-symlink.js +303 -0
  43. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  44. package/dist/owner-dacl-batch-worker.js +54 -0
  45. package/dist/owner-dacl-batch.d.ts +5 -0
  46. package/dist/owner-dacl-batch.js +64 -0
  47. package/dist/owner-dacl.d.ts +2 -0
  48. package/dist/owner-dacl.js +3 -0
  49. package/dist/path.js +17 -1
  50. package/dist/permission-exec.js +3 -6
  51. package/dist/permissions-public.d.ts +1 -0
  52. package/dist/permissions-public.js +1 -0
  53. package/dist/pinned-mutation-admission.d.ts +0 -1
  54. package/dist/pinned-mutation-shared-route.d.ts +2 -8
  55. package/dist/pinned-open.d.ts +0 -1
  56. package/dist/pinned-open.js +1 -2
  57. package/dist/publish-copy-stage.js +4 -0
  58. package/dist/read-opened-file.d.ts +2 -5
  59. package/dist/regular-file.js +3 -3
  60. package/dist/replace-file-copy-fallback.d.ts +1 -2
  61. package/dist/root-context.js +3 -2
  62. package/dist/root-impl.js +0 -3
  63. package/dist/root-move-noreplace.d.ts +2 -7
  64. package/dist/root-observed-path.d.ts +0 -1
  65. package/dist/root-observed-path.js +0 -3
  66. package/dist/root-path-observation.d.ts +4 -11
  67. package/dist/root-path.js +7 -10
  68. package/dist/root-paths.d.ts +2 -6
  69. package/dist/root-remove-identity.d.ts +1 -3
  70. package/dist/root-walk.js +8 -3
  71. package/dist/root-write-admission.js +1 -6
  72. package/dist/root-write-complete-parent.d.ts +2 -0
  73. package/dist/root-write-complete-parent.js +1 -1
  74. package/dist/safe-path-segment.d.ts +1 -0
  75. package/dist/safe-path-segment.js +8 -2
  76. package/dist/secret-file.d.ts +6 -2
  77. package/dist/secret-file.js +1 -0
  78. package/dist/secure-file-windows.js +1 -5
  79. package/dist/secure-file.js +3 -2
  80. package/dist/sidecar-lock-admission-parser.d.ts +1 -2
  81. package/dist/sidecar-lock-handle.d.ts +2 -8
  82. package/dist/sidecar-lock-policy.d.ts +2 -7
  83. package/dist/sidecar-lock-stale-admission.d.ts +1 -5
  84. package/dist/sidecar-lock.js +5 -3
  85. package/dist/staged-symlink-types.d.ts +49 -0
  86. package/dist/staged-symlink-types.js +1 -0
  87. package/dist/symlink-parents.js +58 -7
  88. package/dist/temp-target.js +4 -2
  89. package/dist/temp-workspace-owner.js +4 -9
  90. package/dist/test-hooks.d.ts +1 -1
  91. package/dist/text-atomic.d.ts +2 -1
  92. package/dist/text-atomic.js +2 -0
  93. package/dist/trash.js +27 -1
  94. package/dist/walk.d.ts +2 -5
  95. package/dist/windows-owner.d.ts +0 -1
  96. package/dist/windows-owner.js +0 -1
  97. package/dist/windows-security-bridge.cs +6 -4
  98. package/dist/windows-security-bridge.ps1 +78 -3
  99. package/dist/windows-security-command.d.ts +8 -0
  100. package/dist/windows-security-command.js +66 -15
  101. package/dist/windows-security-facts.d.ts +4 -0
  102. package/dist/windows-security-facts.js +6 -2
  103. package/docs/advanced.md +3 -2
  104. package/docs/archive.md +10 -0
  105. package/docs/atomic.md +11 -3
  106. package/docs/contributing.md +30 -0
  107. package/docs/copy.md +2 -0
  108. package/docs/file-store.md +5 -0
  109. package/docs/guest.md +7 -1
  110. package/docs/install.md +28 -0
  111. package/docs/native-helper.md +11 -4
  112. package/docs/native.md +45 -1
  113. package/docs/permissions.md +66 -0
  114. package/docs/public-api.md +7 -1
  115. package/docs/root.md +10 -1
  116. package/docs/secret-file.md +10 -0
  117. package/docs/security-model.md +4 -1
  118. package/docs/sidecar-lock.md +2 -0
  119. package/docs/staged-symlink.md +123 -0
  120. package/docs/store.md +3 -1
  121. package/docs/testing.md +28 -0
  122. package/docs/walk.md +12 -0
  123. package/docs/writing.md +21 -0
  124. package/package.json +9 -9
@@ -3,24 +3,46 @@ import path from "node:path";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { hasNodeErrorCode, isPathRelativeEscape } from "./path.js";
5
5
  import { assertNoWindowsPathAlias, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
6
+ function outsideRootError(params, root) {
7
+ return new Error(`${params.messagePrefix ?? "Path"} must stay under ${root}.`);
8
+ }
9
+ function pathSegments(value) {
10
+ return value.split(process.platform === "win32" ? /[/\\]+/ : /\/+/)
11
+ .filter((segment) => segment.length > 0 && segment !== ".");
12
+ }
13
+ function rawTargetSegments(root, targetPath) {
14
+ // Resolve only the drive/root or cwd, never the caller's dotdot segments.
15
+ const targetRoot = path.parse(targetPath).root;
16
+ const base = resolvePathPreservingWindowsRoot(targetRoot || ".");
17
+ const absoluteTarget = `${base}${path.sep}${targetPath.slice(targetRoot.length)}`;
18
+ const rootSegments = pathSegments(root);
19
+ const targetSegments = pathSegments(absoluteTarget);
20
+ const fold = (segment) => process.platform === "win32" ? segment.toLowerCase() : segment;
21
+ if (rootSegments.some((segment, index) => fold(segment) !== fold(targetSegments[index] ?? ""))) {
22
+ return undefined;
23
+ }
24
+ return targetSegments.slice(rootSegments.length);
25
+ }
6
26
  function resolvePathWalk(params) {
7
27
  const rawRootDir = params.rootDir;
8
28
  assertNoWindowsPathAlias(rawRootDir, "filesystem", "root dir uses a Windows filesystem namespace alias");
9
29
  const rawTargetPath = params.targetPath;
10
30
  assertNoWindowsPathAlias(rawTargetPath, "filesystem", "target path uses a Windows filesystem namespace alias");
11
31
  const root = resolvePathPreservingWindowsRoot(rawRootDir);
12
- const target = resolvePathPreservingWindowsRoot(rawTargetPath);
13
- const relative = path.relative(root, target);
32
+ const lexicalTarget = resolvePathPreservingWindowsRoot(rawTargetPath);
33
+ const relative = path.relative(root, lexicalTarget);
14
34
  if (isPathRelativeEscape(relative)) {
15
35
  if (params.allowOutsideRoot) {
16
36
  return null;
17
37
  }
18
- throw new Error(`${params.messagePrefix ?? "Path"} must stay under ${root}.`);
38
+ throw outsideRootError(params, root);
19
39
  }
20
- return {
21
- root,
22
- segments: relative && relative !== "." ? relative.split(path.sep).filter(Boolean) : [],
23
- };
40
+ const segments = rawTargetSegments(root, rawTargetPath);
41
+ // A spelling outside the root must not fall back to a normalized walk that
42
+ // could erase a symlink before the filesystem receives the original path.
43
+ if (!segments)
44
+ throw outsideRootError(params, root);
45
+ return { root, segments };
24
46
  }
25
47
  function formatUnsafePath(params, current) {
26
48
  return `${params.messagePrefix ?? "Path"} must not traverse symlinked directory: ${current}`;
@@ -28,18 +50,39 @@ function formatUnsafePath(params, current) {
28
50
  export async function assertNoSymlinkParents(params) {
29
51
  assertNoSymlinkParentsSync(params);
30
52
  }
53
+ function isFilesystemRoot(root) {
54
+ return root === path.parse(root).root;
55
+ }
31
56
  export function assertNoSymlinkParentsSync(params) {
32
57
  const walk = resolvePathWalk(params);
33
58
  if (!walk) {
34
59
  return;
35
60
  }
36
61
  let current = walk.root;
62
+ // `..` may only undo a real directory this walk already lstat'd.
63
+ const walked = [];
37
64
  for (const [index, segment] of walk.segments.entries()) {
65
+ if (segment === "..") {
66
+ const top = walked[walked.length - 1];
67
+ if (top?.kind === "dir") {
68
+ walked.pop();
69
+ current = walked[walked.length - 1]?.path ?? walk.root;
70
+ continue;
71
+ }
72
+ if (top?.kind === "symlink") {
73
+ throw new Error(formatUnsafePath(params, top.path));
74
+ }
75
+ if (!isFilesystemRoot(walk.root)) {
76
+ throw outsideRootError(params, walk.root);
77
+ }
78
+ continue;
79
+ }
38
80
  current = path.join(current, segment);
39
81
  try {
40
82
  const stat = fsSync.lstatSync(current);
41
83
  if (stat.isSymbolicLink()) {
42
84
  if (params.allowRootChildSymlink && path.dirname(current) === walk.root) {
85
+ walked.push({ path: current, kind: "symlink" });
43
86
  continue;
44
87
  }
45
88
  throw new Error(formatUnsafePath(params, current));
@@ -47,9 +90,17 @@ export function assertNoSymlinkParentsSync(params) {
47
90
  if ((params.requireDirectories || index < walk.segments.length - 1) && !stat.isDirectory()) {
48
91
  throw new FsSafeError("not-file", `${params.messagePrefix ?? "Path"} must traverse directories: ${current}`);
49
92
  }
93
+ if (stat.isDirectory()) {
94
+ walked.push({ path: current, kind: "dir" });
95
+ }
50
96
  }
51
97
  catch (err) {
52
98
  if (hasNodeErrorCode(err, "ENOENT") && params.allowMissing !== false) {
99
+ // Win32 can cancel a nonexistent component before filesystem lookup.
100
+ // Returning early would leave later, reachable symlinks unchecked.
101
+ if (process.platform === "win32" && walk.segments.slice(index + 1).includes("..")) {
102
+ throw new FsSafeError("invalid-path", `${params.messagePrefix ?? "Path"} must not cancel a missing directory: ${current}`);
103
+ }
53
104
  return;
54
105
  }
55
106
  throw err;
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { suffixWindowsReservedDeviceName } from "./filename.js";
6
6
  import { sameFileIdentityForCleanup } from "./file-identity.js";
7
- import { assertSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
7
+ import { assertSafePathSegment, normalizeSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges, } from "./safe-path-segment.js";
8
8
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
9
9
  import { hasNodeErrorCode } from "./path.js";
10
10
  import { registerTempPathForExit } from "./temp-cleanup.js";
@@ -60,7 +60,9 @@ function sanitizeExtension(extension) {
60
60
  return token ? `.${token}` : "";
61
61
  }
62
62
  export function sanitizeTempFileName(fileName) {
63
- return suffixWindowsReservedDeviceName(sanitizeSafePathSegment(path.basename(fileName)) ?? "download.bin");
63
+ // Suffix reserved stems before admission so CON.txt stays CON_.txt.
64
+ const suffixed = suffixWindowsReservedDeviceName(normalizeSafePathSegment(path.basename(fileName)));
65
+ return sanitizeSafePathSegment(suffixed) ?? "download.bin";
64
66
  }
65
67
  export function buildRandomTempFilePath(params) {
66
68
  const rootDir = resolveTempRoot(params.rootDir);
@@ -14,13 +14,6 @@ function isNativeCleanupBinding(binding) {
14
14
  typeof binding.removeOwnedTreeSync === "function" &&
15
15
  typeof binding.ownedTreeRemovalAvailable === "function";
16
16
  }
17
- function nativeRemovalError(result) {
18
- if (!result.errorCode)
19
- return undefined;
20
- return Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
21
- code: result.errorCode,
22
- });
23
- }
24
17
  export class TempWorkspaceCleanupCapability {
25
18
  binding;
26
19
  parent;
@@ -309,8 +302,10 @@ export class TempWorkspaceCleanupOwner {
309
302
  this.#capability.assertCurrent();
310
303
  }
311
304
  #mapRemoval(result) {
312
- const error = nativeRemovalError(result);
313
- if (error) {
305
+ if (result.errorCode) {
306
+ const error = Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
307
+ code: result.errorCode,
308
+ });
314
309
  if (error.code === "path-mismatch")
315
310
  return "indeterminate";
316
311
  throw error;
@@ -25,7 +25,7 @@ export type FsSafeTestHooks = {
25
25
  beforeTempWorkspaceNativeRemovalSync?: (quarantinePath: string) => void;
26
26
  beforeTrashMove?: (targetPath: string, destPath: string) => void;
27
27
  afterPublishTargetCreated?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
28
- beforePublishDirectorySync?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
28
+ beforePublishDirectorySync?: NonNullable<FsSafeTestHooks["afterPublishTargetCreated"]>;
29
29
  };
30
30
  export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
31
31
  export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
@@ -1,4 +1,5 @@
1
- export type WriteTextAtomicOptions = {
1
+ import { type ReplaceFileAtomicOptions } from "./replace-file.js";
2
+ export type WriteTextAtomicOptions = Pick<ReplaceFileAtomicOptions, "beforeRename" | "tempPrefix"> & {
2
3
  mode?: number;
3
4
  dirMode?: number;
4
5
  trailingNewline?: boolean;
@@ -12,5 +12,7 @@ export async function writeTextAtomic(filePath, content, options) {
12
12
  copyFallbackOnPermissionError: true,
13
13
  syncTempFile: durable,
14
14
  syncParentDir: durable,
15
+ beforeRename: options?.beforeRename,
16
+ tempPrefix: options?.tempPrefix,
15
17
  });
16
18
  }
package/dist/trash.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { assertSyncDirectoryGuard, createSyncDirectoryGuard } from "./directory-guard.js";
4
5
  import { sameFileIdentity } from "./file-identity.js";
5
6
  import { guardedRenameSync, guardedRmSync } from "./guarded-mutation.js";
6
7
  import { realpathSync } from "./realpath.js";
@@ -72,6 +73,22 @@ function resolveTrashTargetPath(targetPath) {
72
73
  assertNoTrashPathAlias(resolvedPath, "target path");
73
74
  return { path: resolvedPath, resolved: true };
74
75
  }
76
+ function resolveTrashEntryParent(lexicalTarget, targetPath) {
77
+ const lexicalParent = path.dirname(lexicalTarget);
78
+ assertNoTrashPathAlias(lexicalParent, "target path");
79
+ let realParent;
80
+ try {
81
+ // The renamed name lives in this parent. rename follows intermediate
82
+ // symlinks, so a lexical parent inside an allowed root is not enough.
83
+ realParent = realpathSync.native(lexicalParent);
84
+ }
85
+ catch {
86
+ throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
87
+ }
88
+ const resolvedParent = path.resolve(realParent);
89
+ assertNoTrashPathAlias(resolvedParent, "target path");
90
+ return resolvedParent;
91
+ }
75
92
  function assertAllowedTrashTarget(targetPath, allowedRoots) {
76
93
  assertNoTrashPathAlias(targetPath, "target path");
77
94
  const lexicalTarget = path.resolve(targetPath);
@@ -79,11 +96,19 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
79
96
  const stat = fs.lstatSync(lexicalTarget);
80
97
  const resolvedTarget = resolveTrashTargetPath(targetPath);
81
98
  const resolvedTargetPath = resolvedTarget.path;
82
- const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root));
99
+ const parent = createSyncDirectoryGuard(path.dirname(lexicalTarget));
100
+ // Admit the directory entry only when its parent really stays inside an
101
+ // allowed root. Do not admit it because the symlink target is inside.
102
+ const resolvedParent = resolveTrashEntryParent(lexicalTarget, targetPath);
103
+ const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => isSameOrChildPath(resolvedParent, root));
83
104
  if (!isAllowed) {
84
105
  throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
85
106
  }
107
+ // Sync and native realpath can use different Windows short-name spellings.
108
+ // Recheck the retained guard around native containment instead of comparing them.
109
+ assertSyncDirectoryGuard(parent);
86
110
  return {
111
+ parent,
87
112
  path: lexicalTarget,
88
113
  realPath: resolvedTargetPath,
89
114
  realPathResolved: resolvedTarget.resolved,
@@ -91,6 +116,7 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
91
116
  };
92
117
  }
93
118
  function assertTrashTargetGuard(guard) {
119
+ assertSyncDirectoryGuard(guard.parent);
94
120
  const stat = fs.lstatSync(guard.path);
95
121
  if (!sameFileIdentity(stat, guard.stat)) {
96
122
  throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
package/dist/walk.d.ts CHANGED
@@ -20,12 +20,9 @@ export type AsyncWalkDirectoryOptions = Omit<WalkDirectoryOptions, "include" | "
20
20
  include?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
21
21
  descend?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
22
22
  };
23
- export type WalkDirectoryFailure = {
24
- path: string;
25
- relativePath: string;
26
- depth: number;
23
+ export type WalkDirectoryFailure = Pick<WalkDirectoryEntry & {
27
24
  error: unknown;
28
- };
25
+ }, "path" | "relativePath" | "depth" | "error">;
29
26
  export type WalkDirectoryResult = {
30
27
  entries: WalkDirectoryEntry[];
31
28
  scannedEntryCount: number;
@@ -9,7 +9,6 @@ export type WindowsOwnerSummary = Omit<PermissionFailureFields & {
9
9
  daclPresent?: boolean;
10
10
  aces?: WindowsOwnerAce[];
11
11
  aclError?: string;
12
- remote?: boolean;
13
12
  trusted?: boolean;
14
13
  }, never>;
15
14
  export type WindowsOwnerAce = {
@@ -87,7 +87,6 @@ export async function inspectWindowsOwner(params) {
87
87
  sid: ownerSid,
88
88
  currentUserSid,
89
89
  ...parseWindowsAclFacts(parsed),
90
- remote,
91
90
  trusted: !remote && (ownerSid === currentUserSid || TRUSTED_OWNER_SIDS.has(ownerSid)),
92
91
  };
93
92
  }
@@ -312,6 +312,10 @@ public static partial class FsSafeWindowsBridge {
312
312
  }
313
313
  }
314
314
  }
315
+ static object InspectPath(string path) {
316
+ // Raw reporting retains ACL facts when locality is unknown; admission remains strict.
317
+ using(var handle=Open(path,0x00020080,false)) return Security(handle,false);
318
+ }
315
319
  public static object Execute(string operation,string path) {
316
320
  try {
317
321
  object result;
@@ -323,10 +327,8 @@ public static partial class FsSafeWindowsBridge {
323
327
  Require(!handle.IsInvalid,"EBADF","inherited file handle is unavailable");
324
328
  result=Row("identity",Identity(handle),"security",Security(handle));
325
329
  }
326
- } else if(operation=="path") {
327
- // Raw reporting retains ACL facts when locality is unknown; admission remains strict.
328
- using(var handle=Open(path,0x00020080,false)) result=Security(handle,false);
329
- } else throw new Failure("EINVAL","unknown Windows security operation");
330
+ } else if(operation=="path") result=InspectPath(path);
331
+ else throw new Failure("EINVAL","unknown Windows security operation");
330
332
  return Row("ok",true,"result",result);
331
333
  } catch(Failure error) { return Row("ok",false,"code",error.Code,"message",error.Message); }
332
334
  catch(Exception) { return Row("ok",false,"code","EIO","message","Windows security descriptor processing failed"); }
@@ -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 } 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
  }
@@ -50,9 +51,6 @@ function command(operation, params) {
50
51
  },
51
52
  };
52
53
  }
53
- function unverified(message, cause) {
54
- throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
55
- }
56
54
  function record(value) {
57
55
  return value !== null && typeof value === "object" && !Array.isArray(value);
58
56
  }
@@ -79,6 +77,10 @@ function parseReplyValue(stdout, operation) {
79
77
  unverified("Windows security command returned an incomplete response");
80
78
  }
81
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
+ }
82
84
  const codes = new Set(["EACCES", "EPERM", "EEXIST", "ENOENT", "ENOTSUP", "EIO", "EBADF", "ELOOP", "ENOTDIR", "EINVAL", "ENOSPC", "EBUSY"]);
83
85
  if (typeof response.code !== "string" || !codes.has(response.code) || typeof response.message !== "string") {
84
86
  unverified("Windows security command returned an invalid failure");
@@ -115,13 +117,13 @@ class WindowsSecurityCommandError extends PermissionCommandError {
115
117
  timedOut;
116
118
  creationOutcome;
117
119
  processExitConfirmed;
118
- constructor(file, durationMs, receipt, timedOut) {
119
- super(file, durationMs, receipt);
120
+ constructor(file, durationMs, receipt, timedOut, timeoutMs = DEFAULT_PERMISSION_EXEC_TIMEOUT_MS) {
121
+ super(file, durationMs, receipt, timeoutMs);
120
122
  this.timedOut = timedOut;
121
123
  this.creationOutcome = receipt.creationOutcome;
122
124
  this.processExitConfirmed = receipt.processExitConfirmed;
123
125
  if (timedOut)
124
- 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`;
125
127
  if (!receipt.processExitConfirmed)
126
128
  this.message += "; process exit was not confirmed";
127
129
  else if (!receipt.outputClosed)
@@ -159,11 +161,12 @@ export function hasUnsettledWindowsSecurityCommand(error) {
159
161
  return pending.length > 0;
160
162
  }
161
163
  function ignoreLateError() { }
162
- async function execute(operation, params) {
163
- 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;
164
167
  const startedAt = performance.now();
165
168
  return await new Promise((resolve, reject) => {
166
- 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"] });
167
170
  const output = [];
168
171
  const errors = [];
169
172
  let bytes = 0;
@@ -183,7 +186,7 @@ async function execute(operation, params) {
183
186
  const timeout = setTimeout(() => {
184
187
  timedOut = true;
185
188
  fail(deadlineError());
186
- }, DEFAULT_PERMISSION_EXEC_TIMEOUT_MS);
189
+ }, timeoutMs);
187
190
  const finish = (outputClosed) => {
188
191
  if (settled)
189
192
  return;
@@ -196,6 +199,18 @@ async function execute(operation, params) {
196
199
  child.removeListener("error", onChildError);
197
200
  child.on("error", ignoreLateError);
198
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
+ }
199
214
  for (const [stream, collect] of [[child.stdout, collectOutput], [child.stderr, collectErrors]]) {
200
215
  if (!stream)
201
216
  continue;
@@ -224,7 +239,7 @@ async function execute(operation, params) {
224
239
  cause: failure, pid: child.pid ?? null, code: exitCode, signal: exitSignal, stderr: Buffer.concat(errors),
225
240
  processExitConfirmed, outputClosed, terminationSignalSent, terminationError, cleanupErrors,
226
241
  ...(operation === "create" ? { creationOutcome: "unconfirmed" } : {}),
227
- }, timedOut));
242
+ }, timedOut, timeoutMs));
228
243
  }
229
244
  else {
230
245
  try {
@@ -263,7 +278,7 @@ async function execute(operation, params) {
263
278
  if (failed || settled)
264
279
  return;
265
280
  bytes += chunk.length;
266
- if (bytes <= MAX_OUTPUT_BYTES)
281
+ if (bytes <= maxOutputBytes)
267
282
  chunks.push(chunk);
268
283
  else
269
284
  fail(new Error("Windows security command exceeded its output budget"));
@@ -285,7 +300,7 @@ async function execute(operation, params) {
285
300
  };
286
301
  const onClose = (code, signal) => {
287
302
  onExit(code, signal);
288
- if (!failed && performance.now() - startedAt >= DEFAULT_PERMISSION_EXEC_TIMEOUT_MS) {
303
+ if (!failed && performance.now() - startedAt >= timeoutMs) {
289
304
  timedOut = true;
290
305
  failed = true;
291
306
  failure = deadlineError();
@@ -301,11 +316,47 @@ async function execute(operation, params) {
301
316
  child.stderr?.on("error", fail);
302
317
  child.stdout?.on("data", collectOutput);
303
318
  child.stderr?.on("data", collectErrors);
304
- if (!child.stdout || !child.stderr) {
319
+ if (!child.stdout || !child.stderr || (input !== undefined && !child.stdin)) {
305
320
  // Node reports spawn errors on nextTick, which can follow the current microtask.
306
321
  missingPipes = setImmediate(() => { if (!failed && !settled)
307
322
  fail(new Error("Windows security command output pipes are unavailable")); });
308
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);
309
360
  });
310
361
  }
311
362
  export function readWindowsSecurityFactsCommand(targetPath) {
@@ -1,4 +1,8 @@
1
1
  import type { NativeWindowsSecurityFacts } from "./native-binding.js";
2
+ export type DescriptorFacts = Pick<NativeWindowsSecurityFacts, "ownerSid" | "currentUserSid" | "daclPresent" | "isLocal" | "aceListComplete" | "unsupportedAceTypes" | "aces">;
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;
2
6
  /** Raw reporting retains unknown flag bits; secure admission validates them below. */
3
7
  export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
4
8
  /** Native and command observations share the same fail-closed admission policy. */
@@ -6,8 +6,8 @@ const WORLD_SIDS = new Set([
6
6
  "s-1-1-0", "s-1-5-11", "s-1-5-32-545", "s-1-5-7",
7
7
  "s-1-5-32-546", "s-1-5-4", "s-1-5-2",
8
8
  ]);
9
- function unverified(message) {
10
- throw new FsSafeError("permission-unverified", message);
9
+ export function unverified(message, cause) {
10
+ throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
11
11
  }
12
12
  function record(value) {
13
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -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