🛡️ @openclaw/fs-safe


Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.
Platform-specific additions include Linux write leases, Darwin ACL inspection,
and Windows test fixtures.
Think Go's os.Root / OpenInRoot or Rust's cap-std, but for Node. Hand root() a trusted directory and you get back a handle whose every method resolves relative paths against it and defends against .., symlink swaps, hardlink aliases, and TOCTOU rename races. The exact containment strength is reported per mechanism: Linux openat2 opens are kernel-atomic; guarded Linux fallback, macOS, Windows, and JavaScript paths are best-effort.
import { root } from "@openclaw/fs-safe";
const fs = await root("/safe/workspace");
await fs.write("notes/today.txt", "hello\n");
await fs.write("../escape.txt", "x");
That's the whole pitch. root() is the product; the rest of the package — JSON stores, atomic writes, secret files, archive extraction, temp workspaces — is supporting cast for the same boundary.
Full docs and reference at fs-safe.io. For synchronous plugin captures, guarded file copies and batches return owned destination descriptors while preserving per-file checks.
Contents
Why this exists · Not a sandbox · Install · 0.6 migration · Python migration · Quick start · Reading · Subpaths · Failure semantics · Directory durability · Atomic writes · External outputs · Stores · Secure absolute reads · Walking · Archive extraction · Path scopes · Errors · Safety model · Limitations
Why this exists
Most Node code that has to touch caller-controlled paths reaches for:
path.resolve(root, input).startsWith(root)
That validates a string. It does not pin the file you opened, defend against a symlink retarget between check and use, reject hardlinked aliases of out-of-tree inodes, or verify that a write landed where you intended after a rename. The pieces to do those things exist scattered across the ecosystem — write-file-atomic for atomic writes, tar / jszip for archive extraction, various safefs-style convenience wrappers — but none of them give you one root handle with traversal-resistant semantics across every operation.
The same idea has landed in other languages. Go added os.Root and OpenInRoot; Rust has had cap-std for years. Node's fs is path-string-oriented and exposes flags like O_NOFOLLOW but not an ergonomic "operate inside this root" API. fs-safe fills that gap.
path.resolve().startsWith() | string check only | – | – | – | – |
write-file-atomic | – | ✓ | – | – | – |
Go os.Root / Rust cap-std | ✓ | platform | ✓ | ✓ | – |
@openclaw/fs-safe | ✓ | ✓ | ✓ | Linux openat2 atomic; others best-effort | ✓ (ZIP/TAR/gzip/zstd/bzip2) |
Not a sandbox
This is a library-level guardrail, not OS-level isolation. It does not replace containers, seccomp, AppArmor, or filesystem permissions. It is for code that already runs with the privileges of its workspace and wants to stop trivial path tricks from escaping it. If your threat model is a hostile process, you need OS isolation; if your threat model is "an agent, plugin, upload handler, or CLI will eventually be tricked into writing somewhere it shouldn't," fs-safe catches that. The security model describes the exact Linux, macOS, Windows, and JavaScript fallback guarantees and race boundaries.
Install
pnpm add @openclaw/fs-safe
Requires Node.js 22 or newer. Bun 1.4.2 is also supported with the
Bun runtime requirements, including the matching
Rust addon on macOS and Linux. See
Installation for supported platforms and optional dependencies.
Configure native policy before first use:
import { configureFsSafeNative } from "@openclaw/fs-safe";
configureFsSafeNative({ mode: "auto" });
configureFsSafeNative({ mode: "off" });
configureFsSafeNative({ mode: "require" });
FS_SAFE_NATIVE_MODE=auto|off|require selects the same policy. Native-only
operations fail with helper-unavailable when their capability is unavailable.
Guarded JavaScript mutations are best-effort: a hostile peer can redirect a
pathname mutation before its post-check detects the escape. require selects
hardened native paths where documented, but does not make every operation
kernel-atomic. Read the native helper policy and
operation/platform matrix
when concurrent mutation is in scope.
For doctor/startup checks, use
await getFsSafeNativeStatus({ probe: true })
from @openclaw/fs-safe/config to report whether the native addon is active,
why it is unavailable, and what to do next. Calling it without options only reads
the cache. Status is per module instance; Workers must be checked separately.
Migrating from the Python helper
Version 0.5 replaced the Python worker with prebuilt native bindings. Follow the
0.5 migration checklist to replace the removed Python
configuration with native mode selection; current archive changes are covered
in the 0.6 migration guide.
Quick start
import { root } from "@openclaw/fs-safe";
const fs = await root("/safe/workspace", {
hardlinks: "reject",
symlinks: "reject",
mkdir: true,
mode: 0o600,
});
await fs.write("notes/today.txt", "hello\n");
const text = await fs.readText("notes/today.txt");
const config = await fs.readJson("config.json");
await fs.copyIn("uploads/upload.png", "/tmp/upload.png");
await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true });
await fs.remove("notes/archive/today.txt");
root() requires an existing trusted directory. Its defaults apply to each
operation; per-call options handle exceptions. See the Root reference.
write() replaces contents by default. Use create() or overwrite: false
when an existing destination should be an error. move() defaults to no clobber
and requires native support. On Linux, auto can replace a concurrently created
empty directory if no-replace rename is unsupported; use native require for
strict refusal. See the move fallback contract.
Pass overwrite: true when replacement is intended.
Staged JavaScript copies and creates also work when hardlinks are refused
(including Android/Termux), using a verified empty placeholder before atomic
replacement. See the visibility and concurrency tradeoffs.
See Writing for copy sources, mutation authority, symlink
policy, writable handles, and bounded removal, and Creation
for private permissions, atomic creation, and durability options.
Reading
Pick the narrowest read shape that gives you what you need:
await fs.readJson("config.json");
await fs.readText("notes/today.txt");
await fs.readBytes("image.png");
await fs.read("notes/today.txt");
const opened = await fs.open("large.log");
For streams, use open() and the returned FileHandle:
await using opened = await fs.open("large.log");
{
const stream = opened.handle.createReadStream();
}
Root reads default to DEFAULT_ROOT_MAX_BYTES (16 MiB). Pass a larger maxBytes
for expected large reads, or Number.POSITIVE_INFINITY when the caller has a
separate size budget.
See Reading for absolute-path loaders, aliases, and read
budgets, and Writing for writable
handles. Inspection results from stat(), exists(), list(), and entries()
are advisory; use the operation methods for identity checks at the time of I/O.
Subpaths
The main entry point collects the common root, config, output, lock, native-mode,
and error exports. Prefer focused subpaths when a consumer needs a narrower
contract. Low-level helpers that OpenClaw needs to compose higher-level APIs are grouped under
@openclaw/fs-safe/advanced instead of being separate public leaf contracts.
Use @openclaw/fs-safe/pipe for native-owned, reopenable anonymous pipes and one-shot child input.
See the complete subpath catalogue for every entry point and its contents.
Failure semantics in the name
When two helpers behave differently on the same input, the difference is in the name, not the docs.
import { readJson, tryReadJson } from "@openclaw/fs-safe/json";
await tryReadJson("./config.json");
await readJson("./manifest.json");
For one-off structured reads under a trusted root, readRootJsonObjectSync()
performs the root-bounded open and JSON object validation in one step. Use
readRootStructuredFileSync() when the parser lives outside fs-safe, such as
JSON5-backed plugin manifests.
Directory durability
Use the Directory durability reference for directory
receipts, pinned synchronization, and exclusive publication policies, including
whether a completed target is preserved after a parent-directory sync failure.
Atomic writes
replaceFileAtomic() writes a sibling temp and renames it over the destination.
File and parent-directory synchronization are opt-in:
import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
await replaceFileAtomic({
filePath: "/safe/workspace/state.json",
content: JSON.stringify(state, null, 2),
mode: 0o600,
syncTempFile: true,
syncParentDir: true,
});
See Atomic writes for synchronous adapters, fallback recovery,
mutation authority, and publication receipts. Retained lifecycles have separate
contracts: staged files, entry publication,
and staged symlinks.
External outputs
Use writeExternalFileWithinRoot() when a browser download, renderer, media
tool, or native library needs an absolute path to write to:
import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
await writeExternalFileWithinRoot({
rootDir: "/safe/workspace/downloads",
path: "reports/today.pdf",
staging: "sibling",
write: async (filePath) => {
await download.saveAs(filePath);
},
});
The callback receives a staged path. Choose workspace or sibling staging and
producer isolation using the staging-mode guide.
Stores
Use fileStore().json() for small state files that need explicit fallback
reads, atomic writes, and optional sidecar locking around read-modify-write
updates:
import { fileStore } from "@openclaw/fs-safe/store";
const files = fileStore({ rootDir: "/safe/workspace/state", private: true });
const store = files.json<{ enabled: boolean }>("settings.json", { lock: true });
await store.updateOr({ enabled: false }, (current) => ({ ...current, enabled: true }));
See JSON stores for single-path stores and update semantics,
File stores for blobs, streams, and private state, and
File locks for coordination and stale-lock recovery.
The store subpath also provides durable JSON queues.
Use temp workspaces for scoped scratch files and cleanup policies.
Exact file comparison
For exact comparison of already-open files, use
sameFileContentsSync() from advanced. It compares
bytes through both EOFs with bounded memory and preserves the borrowed
descriptors' positions and ownership.
Secure absolute file reads
Use readSecureFile() for an absolute credential path.
It validates permissions, ownership, identity, and size through the opened handle.
See Windows fallback prerequisites
when native support is unavailable.
import { readSecureFile } from "@openclaw/fs-safe/secure-file";
const { buffer } = await readSecureFile({
filePath: "/var/lib/app/token",
label: "auth token",
trust: { trustedDirs: ["/var/lib/app"] },
io: { maxBytes: 16 * 1024, timeoutMs: 5_000 },
});
Use permissions: { allowInsecure: true } only for migration or explicit local-development
flows where a warning is preferable to refusing the file.
Directory walking
Root.entries() lists immediate children without following
child symlinks:
for await (const entry of fs.entries("plugins", { maxEntries: 1_000 })) {
console.log(entry.name, entry.isSymbolicLink);
}
Use Root.walk() for root-bounded recursive traversal of caller-controlled
paths. Standalone walkDirectory() and walkDirectorySync() provide best-effort
inventories; inspect truncated and failedDirs before treating a scan as complete.
See Directory walking for budgets, ordering, filtering, and
cancellation contracts.
extractArchive() handles ZIP and TAR behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets.
import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
const kind = resolveArchiveKind(uploadPath);
if (!kind) throw new Error(`unsupported archive: ${uploadPath}`);
await extractArchive({
archivePath: uploadPath,
destDir: "/safe/workspace/plugin",
kind,
timeoutMs: 15_000,
limits: {
maxArchiveBytes: 256 * 1024 * 1024,
maxEntries: 50_000,
maxExtractedBytes: 512 * 1024 * 1024,
maxEntryBytes: 256 * 1024 * 1024,
maxEntryPathComponents: 64,
},
});
Extraction stages into a private directory and merges through the same safe-open boundary used by direct writes, so a symlinked entry can't trick the merge into following an out-of-tree path.
Advanced path scopes
Use pathScope() for lower-level boundary validation over
a trusted absolute path.
Errors
Boundary and policy failures use FsSafeError with a closed code union.
Parsing, callbacks, and underlying I/O can also throw other error types:
import { FsSafeError } from "@openclaw/fs-safe/errors";
try {
await fs.write("../escape.txt", "x");
} catch (err) {
if (err instanceof FsSafeError && err.code === "outside-workspace") {
}
throw err;
}
For FsSafeError, category distinguishes policy rejections from operational
filesystem or runtime failures. See Errors for codes, receipts,
and other error families; check the error type before branching on its code.
Safety model
Root operations combine confinement, no-follow opens, and identity checks.
The security model describes guarantees and race limits
for native and JavaScript mechanisms on each platform.
Limitations
- Windows native opens are handle-relative and reject reparse points; operations without native wiring use the guarded Node implementation.
- Hardlink rejection depends on platform metadata. Treat it as defense-in-depth, not authorization.
fs-safe does not validate file contents or archive payload semantics beyond filesystem safety constraints. Schemas, signatures, and authorization belong in the layer above.
License
MIT.