Native architecture
Windows native realpath calls and handle-bound canonical-name checks share one native resolver. It keeps DOS-name resolution first and uses normalized NT names on the same handle only after ERROR_ACCESS_DENIED in an AppContainer. Ordinary process errors retain their original code and identity. AppContainer support requires the addon; native-off or unavailable membership leaves Node EPERM unchanged without a subprocess probe. Drive-root translation is uncached and fail-closed; see the AppContainer limits.
See platform filesystem capabilities for Linux write leases, public Darwin ACL facts, and Windows test fixtures.
@openclaw/fs-safe uses native bindings that supply mechanisms Node does not expose directly. The Rust layer is deliberately not a second policy engine. TypeScript owns trusted-root selection, path validation, archive filtering, budgets, modes, identity fencing, cleanup decisions, and error normalization. Rust receives already-decided relative operations and performs the smallest platform syscall sequence that can preserve the boundary.
Every operation that has an equivalent safe Node implementation keeps that guarded JavaScript path. Native loading is lazy; installs do not compile Rust, run postinstall code, or fetch binaries at runtime. Exact-version optional packages are filtered by OS, CPU, and Linux libc, so an installation receives only its matching prebuilt binding. Windows ships both x64 and ARM64 MSVC bindings; selection follows the Node process architecture. An x64 Node process on Windows ARM64 uses the x64 binding under emulation. Native-only operations fail explicitly instead of substituting a weaker implementation.
#FreeBSD 14.4 and newer
The FreeBSD x64 and arm64 packages provide native anonymous pipes, descriptor close and POSIX canonicalization. The larger Linux/macOS/Windows filesystem backend is excluded from these binaries: its Unix gates contain platform-specific resolver, clone, copy, observation and cleanup implementations. FreeBSD does not export stubs for those operations. In auto, each operation selects its existing guarded JavaScript or WASM backend; in require, a missing required capability fails with helper-unavailable. Pure JavaScript APIs retain their ordinary behavior.
| Native capability | FreeBSD binding |
|---|---|
createPipe, closeOwnedFd, canonicalizePath | Implemented; real pipe, descriptor and pathname syscalls |
| Beneath open/create/mkdir/link, replace/no-replace rename and identity-fenced rename | Absent; guarded fallback where supported |
| Directory observations, retained files/symlinks, entry publication, owned-tree and root removal | Absent; guarded fallback or explicit native-only rejection |
| File/tree clone, native copy/hash, archive inspection/extraction and buffer readers | Absent; JavaScript/WASM fallback where supported |
| Native watch | Absent; polling fallback in auto |
| Darwin ACL and Windows security/identity methods | Not applicable |
The release workflow builds inside FreeBSD 14.4 VMs and checks installed-package behavior on the main thread and a real Worker. ARM64 uses QEMU CPU emulation with a real FreeBSD kernel. This is not a Linux cross-build runtime test. Build locally on FreeBSD with pnpm native:build:freebsd; the JavaScript/WASM distribution can be prepared separately with pnpm build. There is no qualified FreeBSD Bun artifact in the runtime matrix. FreeBSD x64 installed-package proof covers npm and pnpm. FreeBSD arm64 covers npm only because pnpm does not publish a native binary for that target. CI prepares the ARM64 proof harness dependencies on Linux and syncs them into the VM; consumer installations and runtime proof run inside FreeBSD, without running pnpm there. Reopening pipe descriptors through /dev/fd requires the standard fdescfs mount there; the VM proof mounts it before testing reopen, inheritance and EOF.
#The beneath model
A trusted directory descriptor is the capability. Native operations accept that descriptor plus a validated relative path and never reconstruct authority from a process working directory. Newly created files use exclusive creation, and TypeScript compares descriptor, pathname, and expected identities before accepting results.
Conceptually, a caller grants authority to an already-open root—not to a path string that can be reinterpreted later:
validated Root handle
└─ relative components (untrusted)
└─ open/link/mkdir beneath the handle
└─ compare descriptor + pathname + expected identity
The TypeScript layer validates and decides. The native layer never decides whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
Ordinary recursive Root.mkdir() calls retain the exact existing parent and every opened missing component for the duration of one synchronous native walk. Each creation is descriptor-relative, followed by fresh Root, retained-parent, child identity and canonical-name checks before the next component. Explicit mutation policies, callbacks, private permissions, aliases and bindings without the required child-open capability retain their existing admission paths.
- Linux uses
openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS), fd-relative - macOS 15.4 and newer first use
openat(O_RESOLVE_BENEATH); older kernels walk - Windows uses handle-relative
NtCreateFilewithOBJ_DONT_REPARSEand
mkdirat/linkat/renameat/renameat2, FICLONE, and copy_file_range. Without openat2, beneath opens use the guarded fallback. Regular-file copies with clone: "auto" also recover when FICLONE returns EAGAIN or EIO, as can happen for dirty extents on ZFS. Recovery requires successful identity-checked removal of the unpublished exclusive clone target before the native ordinary-copy path creates a new stage. clone: "always" still fails when cloning fails. Ordinary-copy I/O errors, clone normalization errors, and cleanup failures remain terminal; generic EIO is never a retry signal.
components with openat(O_NOFOLLOW) and restart in-root symlinks from the pinned root. Both routes apply an F_GETPATH post-open containment detector, but directory rename races mean the result remains best-effort, not race-atomic. macOS uses renameatx_np(RENAME_EXCL) and permits fclonefileat in an owned, non-shared parent. The clone is normalized inside a private staging directory: flags, ACLs, extended attributes, and broad mode bits are cleared before no-replace publication. Clone admission obtains mode, owner, exact identity, flags, and ACL state together from the retained descriptor; immutable receipts are compared across the private staging operation and against fresh no-follow pathname identity reads. Any extended entry on the target parent or private staging directory is rejected before cloning bytes. The payload's cleared ACL and normalized descriptor facts are verified before publication and again, with a fresh published-name identity fence, before its descriptor is returned. Unsupported admission before payload creation or an unsupported clone syscall may still select the documented ordinary-copy path. After the clone creates bytes, normalization and security verification failures report terminal EIO, retaining the original error detail; successful cleanup does not make them eligible for ordinary-copy retry. Cleanup failures remain secondary diagnostics, and already-terminal publication errors such as EEXIST retain their status.
FILE_OPEN_REPARSE_POINT, then explicitly rejects reparse points. Rename and hardlink operations stay rooted in already-open handles. Owner/DACL reads use GetSecurityInfo; private directories receive their protected DACL in an exclusive, handle-relative NtCreateFile call. Their created handles remain open through ACL and pathname-association checks and own any failure cleanup. Admitted, resolved absolute paths use extended-length Win32 spelling for pathname calls, including internal staging and public-name verification; relative child names remain relative to retained handles. Long paths retain the same alias validation, ACL checks, identity fences and cleanup ownership. Already-admitted namespace spellings pass through unchanged, including filesystem-drive paths using \\.\; the encoder does not admit or reject paths. N-API descriptors cross into and out of this layer only through the host executable's paired libuv descriptor bridge; missing or partial exports fail with ENOTSUP instead of trying a raw HANDLE or add-on CRT descriptor namespace. Root.copyIn uses the same ReFS integrity, sparse-file, and cluster-aligned block-clone operations as tree cloning, through retained source and destination parent handles. The exclusive stage enters the existing guarded publication path; transfer failures retain its cleanup receipt. It does not inherit the tree API's source metadata policy.
The internal macOS inspectDarwinAcl(fd) capability reports absent, empty, or present for the opened object's extended ACL. It synchronously borrows the caller's descriptor, preserving its file position and POSIX record locks, and never reopens a pathname. Keep the descriptor open until inspection returns. Darwin's acl_get_entry returns zero for an entry; end-of-list is accepted only for the first entry of a valid, privately owned empty ACL. Unsupported, malformed, and failed inspection is not reported as absence. These facts do not classify individual ACE permissions or prove volume ownership enforcement; each caller applies its own security policy.
#Linux without openat2
Linux kernels before 5.6 and containers whose seccomp policy denies openat2 can keep native mode auto or require. The addon caches one harmless openat2(".") capability probe per process. ENOSYS, or EPERM on that probe, selects the native openat fallback. An EPERM/EACCES from an application operation is still a permission error and never triggers a retry. Install syscall filters before the first native operation; a later ENOSYS fails with ENOTSUP, rather than changing the cached mechanism during a call.
The fallback inspects components with O_PATH | O_NOFOLLOW, follows relative symlink targets using a stack of retained directory descriptors, and rejects absolute targets or .. past the retained root with EXDEV. It permits at most 40 link expansions (ELOOP beyond that limit). Final O_NOFOLLOW and exclusive creation retain their syscall semantics, including opening the link itself with O_PATH | O_NOFOLLOW and creating through a dangling in-root relative link. Exact device/inode/type identities are checked before and after the final no-follow open; followed links also retain their descriptors and have their named identities and target strings rechecked. Detected replacements fail with EXDEV, including changes to directories left behind by a link's .. target.
The fallback also honors nosymfollow mount restrictions on retained links. Two conservative restrictions remain: the fallback refuses to follow any procfs symlink with ELOOP, including ordinary links such as /proc/mounts. Userspace metadata cannot distinguish these from procfs magic links, which RESOLVE_NO_MAGICLINKS must never follow. Opening a final link itself with O_PATH | O_NOFOLLOW remains allowed. In a sticky, world-writable directory, the fallback refuses all symlink following with EACCES, even when the calling thread or directory owner owns the link, or fs.protected_symlinks=0. This preserves Linux's protected-symlink restriction without assuming the thread's filesystem UID or treating equal mapped stat UIDs as proof of equal kernel owners (distinct unmapped owners can both appear as the overflow UID).
Public Root policy and canonical-path admission, hardlink rejection, pinned-file checks, and mutation identity fences remain in place. openBeneath() reports best-effort: these identity samples detect replacements but cannot make a multi-component walk atomic against a hostile process renaming directories between samples. This is the same documented containment class as the macOS and JavaScript paths; applications requiring atomic beneath resolution must check the result or use OS isolation. Rejection after a mutating open does not promise rollback.
Nested no-clobber Root.move() admits both parents and first uses renameat2(RENAME_NOREPLACE). Linux auto can fall back through those same descriptors to identity-checked file link/unlink or directory renameat; the directory fallback can replace an empty directory created concurrently. See the move contract for identity windows and partial-state receipts. require rejects an unsupported capability. Turning native mode off still disables no-clobber moves.
For regular-file writes with no-clobber publication (including Root.create() and archive file extraction), auto removes its identity-checked unpublished stage and retries through the same guarded JavaScript path as native mode off when Linux rejects RENAME_NOREPLACE with EINVAL, ENOSYS, or unsupported-operation errno. This is a definite non-publication, not an uncertain remote rename. Completed streams are replayed from the retained stage descriptor, without calling their producer again. Unsupported capability is cached by the retained parent's exact device number in a process-local cache bounded to 128 devices. Only distinct sibling publication/quarantine/move failures populate that cache; generic cross-parent moves can also reject directory ancestry after a source swap. Other devices and replacement writes retain their native route. require cleans the stage and reports helper-unavailable, naming the operation and renameat2 RENAME_NOREPLACE. Failed cleanup prevents retry. Copies explicitly requiring clone: "always" also fail closed: the JavaScript byte-copy path cannot satisfy that requirement.
Private producer workspace promotion likewise uses its existing guarded JavaScript handoff in auto, preserving the completed producer output. Explicit native-only staged, retained-entry, symlink and directory-replacement contracts fail closed with the same capability diagnostic. Compatible temp-workspace cleanup can use its existing guarded JavaScript quarantine/removal path; require-bounded and native require refuse that downgrade. These publication/cleanup owners keep their own recovery contracts. Regular-file publishFileExclusive with rename-noreplace also opts into the retained-parent file-move fallback in Linux auto, reporting fallback: "link-unlink"; require still refuses. This fallback is not crash-atomic: see publication strategies and partial failure receipts. The standalone movePathWithCopyFallback() uses guarded replacing rename and its existing cross-device copy policy, so unsupported no-replace flags do not affect it.
Bounded owned-tree cleanup deliberately has no openat fallback: RESOLVE_NO_XDEV rejects bind mounts even when device numbers match, which ordinary identity checks cannot reproduce. cleanupSafety: "require-bounded" fails before workspace creation with helper-unavailable; low-level cleanup opens report ENOTSUP. Compatible cleanup retains its documented behavior. Low-level O_TMPFILE anonymous opens also fail before creation with ENOTSUP in the fallback, because named-entry identity checks cannot verify an unnamed file. Public staged-write APIs use exclusive named files and remain available.
For tests, set FS_SAFE_TEST_NO_OPENAT2=1 before starting Node to force the fallback (including bounded-cleanup refusal). It is read only at the first capability probe. It has no effect on macOS or Windows. See Linux fallback testing.
#Archives
Native ZIP and TAR entry reads retain a private, unpooled input Buffer in the async reader. ZIP reuses its parsed directory; TAR retains admitted member offsets. TypeScript validates and selects the requested member before reading. The internal binding borrows that allocation: it must never be mutated or detached while the reader or a read task exists. The public API only accepts a pathname and owns this buffer exclusively. An N-API reference keeps the bytes alive; a mutex serializes access to the retained ZIP cursor. Plain TAR copies only the selected range after complete admission, while gzip, zstd, and bzip2 replay bounded decompression and validate the full physical stream. Concurrent TAR reads share immutable input and own separate decoder state. Each output owns a new vector, which N-API transfers to Node without a second payload copy on runtimes supporting external buffers. No entry-read path requires temporary-file staging. Extraction retains its private staged input.
Rust streams ZIP and TAR payloads, including gzip, zstd, and bzip2. It first returns a bounded manifest. TypeScript applies the shared path, filter, strip, mode, and byte policies and returns an index-bound extraction plan. Rust then creates only those planned entries beneath a private staging descriptor.
The fs-safe-archive-core Rust workspace crate owns TAR framing, paths, types, mode decoding, GNU metadata, and byte-counted local PAX records. The native binding and bundled WASM module compile the same source. No tar::Archive or Node TAR parser reinterprets admitted identities or sizes. Executors replay admitted payload ranges after complete bounded admission; native writes retain the platform's descriptor-relative primitives, while fallback writes retain the guarded Node staging/publication boundary. ZIP behavior is unchanged. maxMetaEntryBytes bounds bodies before allocation; unsupported global/old metadata and sparse forms fail closed. See bounded local PAX support.
The bundled module also compiles the same zstd and bzip2 codec implementations used by native. In off or missing-native auto, those decoders feed the shared TAR parser through fixed 64 KiB windows in one import-free WASM session with a 256 MiB linear-memory ceiling. Gzip retains Node's built-in decoder. Complete container and TAR admission precedes policy evaluation and guarded publication; concatenated compressed members and zstd skippable frames are consumed through physical EOF. No runtime command, interpreter, download, or consumer compilation is needed for these archive routes. require stays strict, and available native operation failures do not retry through WASM. Public inspectTarArchive() still accepts only plain TAR/gzip; ZIP fallback still requires optional JSZip.
Every raw pass receives only TypeScript's resolved maxEntries, maxMetaEntryBytes, and maxDecodedBytes. Shared resolution caps metadata and decoded byte fields at JavaScript's safe-integer maximum and entry counts at 2^32 - 1 before backend selection. Large finite limits remain accepted; native conversion mirrors those caps and rejects malformed non-finite or negative direct-call values before casting. Logical member headers count before filtering/stripping; metadata records do not. maxEntryBytes and maxExtractedBytes remain exclusively in TypeScript's accepted-plan builder, after strip/filter policy, and are absent from the raw meter's interface. Bounded reads use the default count/metadata/decoded bounds; public maxBytes bounds only the requested output. TypeScript derives the internal decoded cap by safely adding maxExtractedBytes and maxArchiveBytes, clamped to the safe integer maximum. Every native pass receives that same cap and charges headers, metadata, bodies, padding, EOF blocks, and trailing zeros. It rejects overflow with archive-decoded-size-exceeds-limit; no ratio policy is implied. Extraction and entry reads drain the metered reader through physical EOF after admitted-range replay. Trailing framing or decoded-limit failures propagate before directory modes are finalized, staging is published, or selected bytes return. Native gzip uses the existing flate2 member decoder with an explicit bounded member/padding transition; JavaScript retains Node gunzip and validates its unconsumed compressed suffix. Only all-zero physical padding after a complete validated trailer is accepted, still within the original archive-byte budget. Native reads stop at framing boundaries so a rejected header does not request its body from the decoder; codec buffering can still read ahead internally. Inspection finishes the complete bounded framing pass before returning its manifest. Directory and link bodies, missing two-block EOF, and nonzero trailers reject on both backends, as detailed in raw TAR framing. Raw and padded sizes above JavaScript's safe-integer maximum reject as invalid framing before applying member budgets, including when local PAX overrides the size.
#Publication and hashing
Exclusive publication tries a hardlink, then a copy-on-write clone, Linux copy_file_range, and finally the existing asynchronous JavaScript byte loop. All routes preserve wx semantics and the same source/target identity and SHA-256 fencing. Native hashing and Linux whole-file copying run on N-API async workers rather than the JavaScript event loop.
Linux range copying confirms every zero-byte result with a positioned source read at the current transfer offset, including after earlier calls copied data. If readable bytes remain, automatic Root copying resumes its byte loop from that offset; exclusive publication removes its partial target before retrying the guarded byte-copy fallback. EOF checks preserve descriptor cursors and do not bypass the byte limit.
#Mode semantics
See Native helper policy for the auto, require, and off contracts and cached loader behavior. See What needs the native binding for the exceptions to fallback availability.
sha256FileSync() is a synchronous Node implementation in all three modes and does not load the binding. Use asynchronous sha256File() for native hashing and cancellation that can respond while JavaScript callbacks run.
Features without a safe fallback, including no-clobber Root.move() and retained-directory staging, fail with helper-unavailable when native support is absent or off. Staging is currently Linux/macOS only and rejects Windows with unsupported-platform.
For an ordinary direct child, native Root.mkdir() retains the already-admitted Root directly instead of running a parent-creation walk. A missing portable single-component name also skips general alias resolution after a fresh Root identity check and no-follow missing-entry observation; retained Root and child identity checks still surround creation. Its short descriptor open, creation, identity checks, and close are synchronous; the public method remains asynchronous. Root and child pathname/canonical identities are checked before success. Ordinary portable paths with existing canonical parents retain the admitted parent directly. A fresh exact parent snapshot must match its beneath-opened descriptor; parent, child and Root name/identity checks still surround creation. Ordinary missing parents use the retained descriptor walk described above when the binding can create and open a child together. Aliases, private creation, mutation policies, callbacks and older bindings retain their existing admission paths. Policy-free ensureRoot() verifies the retained native Root directly. In off and auto, ordinary Root.mkdir() keeps its metadata-only creation syscall in the synchronous guard sequence; supplied mutation callbacks, mutation policies, and private creation retain their existing asynchronous path. Both public methods continue to return promises.
Native removal and moves also retain Root synchronously and reuse that descriptor when Root is an immediate parent. Nonrecursive removal keeps its own leaf path, including fresh policy, identity and post-removal checks; recursive traversal retains its directory, mount, budget and cancellation checks. Neither optimization changes the descriptor-relative mutation or atomic no-replace mechanism.
Ordinary Root.ensureRoot() in require can combine retained-root opening, exact identity/metadata/canonical observations, the final named Root fence and checked close in one native call. The initial JavaScript Root identity fence remains before native lookup. Verification and close failures retain both errors; an unavailable native observer hands its still-open descriptor to the existing guarded observation route. Private or policy-bound calls keep their admission.
Writable opens without an explicit mutation policy attempt the existing-file handle path before creating parents. Policy-bound opens first prepare the parent receipt needed to select the file. In require, missing files and parents still use native confined creation; existing files keep their guarded Node handle path, identity fences and hardlink checks. Required creation capabilities are checked even when the file exists.
For ordinary portable missing-file paths with an existing canonical parent, native writable creation retains that parent's exact identity through beneath admission. An absence observation selects exclusive native creation; an entry that appears concurrently returns to the checked existing-file open. The public FileHandle handoff and all post-open identity, link-count and canonical checks remain in place. Explicit policies, authority callbacks, aliases and missing parents retain their full admission path.
Recursive removal in both implementations retains exact directory identities at admission. Repeated fences use ordinary numeric stats only when both captured identity components are safely representable; larger identities keep bigint observations, and unknown Windows components receive the existing bounded exact retry. Every directory identity and canonical-path observation remains in place.
Native publication retains Root through synchronous descriptor open/close and checks canonical staging directories against their retained descriptors. Older bindings keep the existing guarded directory verification. Owned-descriptor staged buffers up to 64 KiB can write synchronously when no mutation assertion is configured; direct-publication buffers, larger buffers, streams, and assertion-bearing writes keep their asynchronous path. Publication, permission, and post-operation identity checks remain unchanged.
Asynchronous temp workspaces create their exclusive child relative to the retained cleanup parent when the binding provides that operation. The returned descriptor remains owned through the existing mode, parent, ancestry and final pathname/descriptor checks. Collisions retry with a fresh random name and never adopt or remove the existing directory. Capability probing still completes before creation. Synchronous creation and older bindings retain their existing routes.
Windows owner/DACL inspection, private-directory creation, and secure-file inspection support Windows helper fallbacks in auto and off. require rejects missing capabilities, and native operation failures are terminal.
The staged-file owner also serves POSIX native pinned writes, including streaming. Unpublished files remain at 0600; requested modes are applied through the owned file descriptor only after rename and published-entry identity validation. Post-rename chmod or sync failures retain the publication receipt and final name. Its direct-child exclusive openat hands off the descriptor before any fallible post-open checks; non-following statat compares against that descriptor with exact native identities, and cleanup uses unlinkat in the retained parent. The separate checks and unlink are not atomic conditional deletion. Windows pinned writes and other fallback-capable APIs retain their existing mechanisms. Native writers share root and parent admission, but keep their platform identity checks and leaf ownership. POSIX coordinator disposal uses SuppressedError to retain both an operation failure and a disposal failure, including their receipts; stage preparation and cleanup keep their documented error mappings. Root replacement verification borrows the published descriptor after final mode application, while a private coordinator retains the staged owner until the asynchronous check finishes. The owner never escapes that coordinator; public staging methods and receipts expose no descriptor or verification callback. Verification failures preserve the published name, and disposal still retains both verification and cleanup errors when both fail. The private verification channel carries exact bigint identity from the original owned descriptor (or the content-accepted FUSE descriptor). Root compares it against exact fd and pathname metadata; legacy helper return facts and public read metadata behavior are unchanged. Missing Windows pathname identity still requires a guarded path reopen and comparison with the original retained file; that fallback does not apply to POSIX no-read modes.
#Windows buffered-write cost
Root.write(..., { durable: false }) skips durability synchronization, but still stages and atomically replaces the file, admits the existing destination, and checks the root, parent, published pathname and retained descriptor identities. Raw Node writes truncate the destination in place and are not equivalent work.
A Windows x64 measurement of source revision fe216e22 used Node 24.21.0, a 16-logical-processor hosted runner, and 23-byte replacements. Across 21 samples of 100 writes, uninstrumented median sample means were 1.912 ms in off and 1.233 ms in require; their raw Node controls were 0.246 ms and 0.255 ms. Paired raw/raw A/A ratios were 1.011 and 1.009. These are observations on that runner, not latency guarantees or a same-process comparison between native modes.
Separate instrumentation recorded every Node filesystem and native binding call over 101 warmed 22-byte replacements. The following times are median cumulative microseconds per write for each call family; instrumentation adds overhead and these rows must not be substituted for the uninstrumented timings above.
| Call family | off calls / µs | require calls / µs |
|---|---|---|
Canonicalize pathname (realpathSync.native) | 14 / 797 | 4 / 265 |
Exact file and directory metadata (lstat, stat, fstat) | 41 / 365 | 22 / 166 |
| Node asynchronous open | 2 / 272 | 1 / 74 |
| Native beneath open: parent, stage, published file | 0 / 0 | 3 / 174 |
| Native parent observation | 0 / 0 | 1 / 45 |
| Write payload | 1 / 95 | 1 / 50 |
| Replace by rename | 1 / 199 | 1 / 173 |
The native stage creation accounted for about 141 µs of its beneath-open total; the published-file reopen accounted for about 22 µs. Native rename uses handle-relative NtSetInformationFile, not MoveFileEx. Neither mode invoked the security helper, queried ACLs, changed mode, or flushed a file in these non-private, non-durable writes. Path and identity observations explain much of the remaining cost; a hidden helper process or durability flush does not.
These measurements did not isolate antivirus/indexer time inside filesystem calls and do not establish a universal NTFS lower bound. Storage, pathname depth, Windows version and security software can change the result. Repeated identity observations fence different admission and publication boundaries; their count alone does not establish that an observation can safely be removed.
#What needs the native binding
In off and missing-binding auto, these capabilities are unavailable. Other APIs keep their guarded fallback; the delta table describes its strength. Installing a binding does not add support on an excluded platform or filesystem.
| API or option | Binding requirement without a fallback |
|---|---|
Root.move() with overwrite: false (the default); replaceDirectoryAtomic() | helper-unavailable; atomic no-replace publication cannot use check-then-rename. |
stageFileInDirectory(), retainSymlinkInDirectory() | Linux/macOS: helper-unavailable; other platforms: unsupported-platform. |
retainEntryForPublication() | Linux/macOS/Windows: helper-unavailable; other platforms: unsupported-platform. |
retainFileInDirectory() | Windows retention only; returns an unsupported admission with helper-unavailable rather than throwing. |
createPipe() | Linux/macOS/FreeBSD: helper-unavailable; other platforms: unsupported-platform. |
tryAcquireWriteLease() | Linux: helper-unavailable; other platforms: unsupported-platform. |
createCloneSource(); copyTree() or Root.copyIn() with clone: "always" | helper-unavailable; synchronous copyRootFileSync() / batch copying with clone: "always" reports unsupported-platform. Ordinary automatic byte copying remains available. |
readCloneFileMetadata(); inspectDarwinAcl() | macOS: metadata reading throws helper-unavailable; ACL inspection returns { kind: "unknown", reason: "helper-unavailable" }. Elsewhere, they return unsupported/unknown facts. |
Root.copyIn() with sourceSymlinks: "copy-link" | macOS/Windows: helper-unavailable; Linux/FreeBSD retain the guarded literal-link path. |
watch() with mode: "events" | helper-unavailable; mode: "auto" uses polling. |
Temp workspace/file helpers with cleanupSafety: "require-bounded" | helper-unavailable; the default "compatible" cleanup remains available. |
| Private creation on macOS | helper-unavailable for descriptor ACL admission: createDirectory[Sync]() / createFileSync() and Root creation with private: true, Root.createJson() and Root.ensureRoot() with private: true. |
Windows test hooks holdWindowsSharingLock(), setWindowsFileAttributes(), readWindowsFileExtents() | Windows: helper-unavailable; other platforms: unsupported-platform. |
preserveMetadata: true does not require native support in auto or off: see copy metadata and source links for timestamp precision and Windows attribute differences. Archive fallbacks include bundled WASM, and Windows security fallbacks use the packaged prebuilt .NET Framework helper; neither requires the optional native binding.
#JavaScript fallback guarantees and delta
Policy-bound parent creation can refresh exact directory facts through the retained POSIX descriptor. The optional native observer compares descriptor and no-follow pathname metadata and verifies the descriptor's canonical path before and after the observation. Ordinary paths can share that observation within one synchronous admission phase; callbacks, mutations, and later phases require fresh evidence. Unsupported helpers retain the guarded pathname checks. Policy and denied-path decisions remain in TypeScript.
Public policy does not change with the selected mechanism: traversal and link rejection, archive filters/limits/modes, exclusive target creation, source and target identity fencing, publication cleanup receipts, and secret/lock policy remain TypeScript-owned. Windows buffered replacement writes with an omitted mutationSymlinks policy retain the existing final-link and parent-junction differences between native and legacy JavaScript paths; use explicit "reject" for uniform link rejection. Apart from that legacy compatibility exception, what changes is the syscall strength or availability:
The table compares underlying mechanisms. On Node, public Root.open(), Root.read(), and Root.openWritable() use guarded Node file opens and report containment: "best-effort" in every native mode. require checks availability when an operation requests native support; it does not upgrade those results. See Root containment guarantees.
| Capability | Native path | Guarded JavaScript path |
|---|---|---|
| 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. |
| 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. |
| 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. |
| 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. |
rename-noreplace | Atomic platform no-replace rename; Linux auto file publication can use identity-checked, non-crash-atomic link/unlink when unsupported. | Unsupported; no emulation by check-then-rename. |
| Windows DACL read | Direct GetSecurityInfo; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. Secure-file reads query the borrowed open descriptor and compare its 32-bit volume serial and 64-bit file-index projection with Node's bigint receipt. | The packaged prebuilt .NET Framework helper preserves raw facts and inspects the borrowed descriptor for secure reads, with the same Node identity comparison; its command failures reject. Structured .NET pathname reporting retains its separate compatibility query. |
| Windows private directory | Creation-time protected DACL. | The packaged prebuilt .NET Framework helper applies the protected DACL at creation and retains exact handles through identity validation and failure cleanup. Command failures reject. |
Use off in CI to keep the fallback contract exercised. Use require when a deployment depends on the stronger mechanism or a native-only feature; do not infer native loading from timing.
#Windows helper build
pnpm build:windows-helper builds the portable security executable with the .NET SDK pinned by global.json, Roslyn deterministic compilation, a fixed source path map, no debug symbols, AnyCPU and Prefer32Bit=false. The project under native/windows-security-helper/ targets .NET Framework 4.6.2 using Microsoft's Microsoft.NETFramework.ReferenceAssemblies 1.0.3 and its committed NuGet lock file; builds restore in locked mode. CI compares Ubuntu and Windows SHA-256 hashes to enforce byte-identical unsigned output. Release builds repeat this comparison before Authenticode signing on an x64 Windows runner. The signing manifest records that the helper's pre-signing SHA-256 equals both reproducible build hashes; its signed SHA-256 is checked against the exe inside the final npm tarball. Timestamped signatures change the distributed bytes, so rebuilding source is expected to reproduce the unsigned hash, not the signed file. Both Windows addons and the helper must have a valid, timestamped embedded signature from OpenClaw Foundation before signed x64/ARM64 runtime tests and release collection. See release prerequisites for setup and the publication-free dry run. The main package includes only the exe; the three C# source files remain available in the repository. The separate windows-owner-query.cs compatibility query preserves the former PowerShell query's .NET API sequence, normalization, link behavior, and locality rules; the raw security bridge remains independent.
The 4.6.2 target selects modern System.IO path handling on 4.6.2+ runtimes, matching Windows PowerShell 5.1. An executable targeting an earlier framework gets legacy handling that rejects extended-length \\?\ paths with "Illegal characters in path." The helper still uses only APIs available in .NET Framework 4.0 and the same CLR v4.0.30319; it needs no .exe.config sidecar. Locked restore pins the Microsoft.NETFramework.ReferenceAssemblies.net462 package to 1.0.3 with its content hash. The raw bridge keeps extended paths out of .NET normalization and uses CreateFileW; its security checks are unchanged.
Build output is copied beside the source modules for Vitest and beside the compiled modules in dist/; both use the same module-relative filename. pnpm build can skip compilation on non-Windows developer machines without the SDK, retaining a previously built source-side copy when present. Packing still requires the executable. CI and release assembly install the pinned SDK and build it, including package smoke tests on other operating systems.
Each invocation still inspects the inherited borrowed handle with GetStdHandle(-10) and exits. A persistent helper cannot receive replacement fds opened later: portable Node exposes neither the underlying OS HANDLE nor DuplicateHandle. Per-call processes preserve descriptor ownership and all existing checks across callbacks, publication, link-count changes, and chmod. There is no security-fact cache or pathname substitution for descriptor checks.
#Loader security
Native initialization runs in the async context captured when fs-safe's loader module is evaluated. Import fs-safe during application startup, outside request or lease scopes, so the addon's process-lifetime housekeeping cannot retain the first operation's AsyncLocalStorage stores. Importing still does not load the addon: only the first native operation or explicit await getFsSafeNativeStatus({ probe: true }) call pays the registration cost. The config subpath defers the internal loader until status is requested and shares that loader's cache with native operations. Subsequent operations and their callbacks retain their own caller context. Dynamically importing fs-safe for the first time inside a request scope captures that import scope instead; this boundary does not clear an already active import context.
Importing fs-safe never executes a child process. Linux libc selection uses the Node process report, then the ELF PT_INTERP field of process.execPath, then conventional musl library filenames. An installed compatibility loader does not override the running executable's interpreter. If all probes are inconclusive, the loader conservatively attempts the glibc package and lets normal module loading fail into auto fallback. The loader requires only the package selected from the detected target; it never probes unrelated packages, downloads code, or runs a postinstall step. A missing or incompatible binary selects the guarded fallback in auto, throws typed helper-unavailable in require, and is never inspected in off. Tests reject child_process, exec, or spawn usage in the loader. Windows security/private-creation fallbacks emit an actionable, path-free warning; other fallbacks remain silent. See fallback troubleshooting.
#Anonymous pipes
The lean pipe subpath creates native-owned anonymous pipes. Linux and FreeBSD use atomic close-on-exec creation; Darwin reports its non-atomic flag setup explicitly. Each end must close through its native owner, including when a Node stream uses it in a Worker.