Skip to content

Latest commit

 

History

949 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🛡️ @openclaw/fs-safe

fs-safe banner

npm ci node license docs

Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.

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");   // ok
await fs.write("../escape.txt", "x");            // throws FsSafeError("outside-workspace")

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.

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.

Root boundary Atomic writes Symlink/hardlink defense TOCTOU resistance Archive extraction
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" });    // default: native when available
configureFsSafeNative({ mode: "off" });     // disable the addon; use supported fallbacks
configureFsSafeNative({ mode: "require" }); // fail closed if the operation's native capability is unavailable

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.

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 for the atomic collision decision; it fails with helper-unavailable when unavailable. Pass overwrite: true when replacement is intended.

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"); // parsed value; validate it at your boundary
await fs.readText("notes/today.txt");
await fs.readBytes("image.png");
await fs.read("notes/today.txt"); // { buffer, realPath, stat }
const opened = await fs.open("large.log"); // FileHandle for streaming

For streams, use open() and the returned FileHandle:

await using opened = await fs.open("large.log");
{
  const stream = opened.handle.createReadStream();
  // consume stream
}

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.

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"); // returns null on missing or invalid
await readJson("./manifest.json");  // throws on missing or invalid

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("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.

Archive extraction

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") {
    // handle
  }
  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.

About

Race-resistant root-bounded filesystem primitives for Node.js.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

62 stars

Watchers

2 watching

Forks

Releases

Contributors

Languages