Skip to content

openclaw/fs-safe

Repository files navigation

@openclaw/fs-safe

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

Use this when trusted application code has to touch caller-controlled paths inside a directory it owns. The package gives you one root() boundary that survives symlink swaps, .. traversal, hardlink aliases, and TOCTOU rename races between check and use.

Why

path.resolve(root, input).startsWith(root) validates a string. It does not pin the file you opened, defend against a symlink retarget between check and use, reject hardlinked aliases, or verify that a write landed where you intended after a rename. fs-safe does those things, packaged so every call site picks up the same defense without re-implementing it.

This is a library-level guardrail, not OS-level isolation. It does not replace containers, seccomp, 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.

Install

pnpm add @openclaw/fs-safe

Node 20.11 or newer. Core root/path/json/temp helpers avoid framework dependencies; archive helpers use jszip and tar for ZIP/TAR support.

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() takes the trusted directory; relative paths in subsequent calls are resolved against it. Defaults you pass to root() apply to every call below; per-call options override them.

When you need metadata or a FileHandle:

const { buffer, realPath, stat } = await fs.read("notes/today.txt");
const opened = await fs.open("notes/today.txt");

create() is the don't-clobber variant of write() and throws already-exists when the target already exists:

await fs.create("notes/README.md", "seed\n"); // throws if it already exists

write() replaces file contents by default; pass { overwrite: false } or use create() when an existing file should be an error. move() defaults to no clobber because it can otherwise delete an unrelated target while also consuming the source. Pass { overwrite: true } when replacing the target is intended.

Use ensureRoot() when a computed relative directory target resolves to the root itself ("" or ".") and you want the operation to be accepted. root() still requires the trusted root directory to already exist.

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.

reader() returns a callback that reads absolute or relative paths through the same root boundary. It is useful for APIs that accept a (path) => Promise<Buffer> loader. Absolute paths outside the root are rejected with outside-workspace. readAbsolute() has the same absolute-path behavior directly.

When you need a writable FileHandle, use openWritable() and prefer await using for cleanup:

await using opened = await fs.openWritable("logs/current.log", { writeMode: "append" });
{
  await opened.handle.appendFile("line\n");
}

nonBlockingRead is the only I/O scheduling knob in RootDefaults; it applies to read/open operations because it changes how file descriptors are opened. Filesystem safety policy remains explicit through hardlinks and symlinks.

stat(), exists(), and list() are boundary-checked, but they cannot pin a later operation to the same filesystem object. Use read(), open(), write(), create(), copyIn(), move(), or remove() for operations that must be race-resistant at the point of use.

Subpaths

The main entry point is intentionally small: root, the root option/result types, and FsSafeError. Use subpaths for everything else. 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.

Subpath Contents
@openclaw/fs-safe/root root(), Root, RootDefaults, related types
@openclaw/fs-safe/path canonical path checks: isPathInside, safeRealpathSync, isNotFoundPathError, isSymlinkOpenError
@openclaw/fs-safe/json tryReadJson, readJson, readJsonIfExists, writeJson, writeText, sync variants
@openclaw/fs-safe/store fileStore, jsonStore, and privateStateStore
@openclaw/fs-safe/secret strict and result-shaped secret file read/write helpers
@openclaw/fs-safe/atomic replaceFileAtomic, replaceFileAtomicSync, replaceDirectoryAtomic, movePathWithCopyFallback
@openclaw/fs-safe/temp tempWorkspace, tempWorkspaceSync, tempFile, writeSiblingTempFile, resolveSecureTempRoot
@openclaw/fs-safe/secure-file fd-pinned absolute file reads with owner, mode, ACL, trusted-dir, size, and timeout checks
@openclaw/fs-safe/permissions POSIX mode and Windows ACL inspection plus remediation formatting helpers
@openclaw/fs-safe/walk budget-bounded directory walking with symlink policy, filters, and truncation accounting; not root-bounded
@openclaw/fs-safe/archive extractArchive, resolveArchiveKind, ArchiveLimitError, preflight helpers
@openclaw/fs-safe/advanced lower-level composition helpers such as path scopes, pinned open, sidecar locks, install paths, filename sanitizing, local-root readers, regular-file helpers, pathExists, and withTimeout; less stable than focused public subpaths
@openclaw/fs-safe/errors FsSafeError, FsSafeErrorCode
@openclaw/fs-safe/types shared types: DirEntry, PathStat, …
@openclaw/fs-safe/test-hooks hooks the test suite uses to inject races; only active under NODE_ENV=test

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
import { pathExists } from "@openclaw/fs-safe/advanced";

await pathExists("/safe/workspace/link"); // follows fs.stat() — broken symlinks return false

Atomic writes

replaceFileAtomic() writes a sibling temp file, optionally fsyncs it, and renames it over the destination. Mode preservation, rename retry / copy fallback on EPERM, parent-directory fsync, and a beforeRename hook for backup or observer flows are all 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,
});

replaceFileAtomicSync() covers the synchronous case with the same options shape. Both accept an injectable fileSystem for tests.

Stores

Use jsonStore() for small state files that need explicit fallback reads, atomic writes, and optional sidecar locking around read-modify-write updates:

import { jsonStore } from "@openclaw/fs-safe/store";

const store = jsonStore({
  filePath: "/safe/workspace/state/settings.json",
  lock: true,
});

await store.updateOr({ enabled: false }, (current) => ({ ...current, enabled: true }));

Use update() when missing state is part of your model; use updateOr() for the common merge-into-defaults case. Standalone helpers use options bags because they do not carry a bound root and often need multiple authority, path, and policy knobs.

Use fileStore() for cache/blob/media-style directories where callers need safe relative paths, size limits, atomic replacement, stream writes, and TTL cleanup behind one root:

import { fileStore } from "@openclaw/fs-safe/store";

const media = fileStore({
  rootDir: "/safe/workspace/media",
  maxBytes: 5 * 1024 * 1024,
  mode: 0o600,
});

await media.write("inbound/photo.jpg", bytes);
const opened = await media.open("inbound/photo.jpg");
await media.pruneExpired({ ttlMs: 10 * 60 * 1000, recursive: true });

tempWorkspace() also exposes writeText(), writeJson(), and copyIn() for single-file scratch workflows without hand-rolled path joins.

tempFile() is the smaller one-file temp helper. It defaults to the secure fs-safe temp root, supports await using, and exposes file(name) for additional sibling paths inside the same private temp directory:

import { tempFile } from "@openclaw/fs-safe/temp";

await using target = await tempFile({ prefix: "download", fileName: "payload.bin" });
await fs.promises.writeFile(target.path, bytes);
const checksumPath = target.file("payload.sha256");

Secure absolute file reads

Use readSecureFile() when the caller gives you an absolute credential path instead of a root-relative workspace path. It opens the file first, validates the same handle it will read from, checks trusted directories, owner, POSIX mode or Windows ACLs, hardlink count, size, and optional timeout, then reads through the pinned handle.

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

walkDirectory() and walkDirectorySync() replace ad-hoc recursive readdir() loops with entry and depth budgets, a symlink policy, and stable relative paths.

import { walkDirectory } from "@openclaw/fs-safe/walk";

const scan = await walkDirectory("/safe/workspace", {
  maxDepth: 4,
  maxEntries: 10_000,
  symlinks: "skip",
  include: (entry) => entry.kind === "file",
});

for (const file of scan.entries) {
  console.log(file.relativePath);
}

Check scan.truncated before treating the result as complete.

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,
  },
});

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

For code that already has a trusted absolute path and wants lower-level boundary validation without going through root():

import { pathScope } from "@openclaw/fs-safe/advanced";

const uploads = pathScope("/safe/uploads", { label: "uploads directory" });
const files = await uploads.files(["photo.jpg"]);
const target = await uploads.writable("report.pdf");

Errors

Every failure surfaces as an FsSafeError with a closed code union you can branch on:

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;
}

Codes are grouped by category:

if (err instanceof FsSafeError) {
  if (err.category === "policy") {
    // Unsafe caller input or filesystem state.
  } else {
    // Operational problem such as helper startup, timeout, or unverifiable permissions.
  }
}

Current FsSafeErrorCode values are already-exists, hardlink, helper-failed, helper-unavailable, invalid-path, insecure-permissions, not-empty, not-file, not-found, not-owned, not-removable, outside-workspace, path-alias, path-mismatch, permission-unverified, symlink, timeout, too-large, and unsupported-platform.

Safety model

  • root-bounded APIs resolve paths against a configured root and reject canonical escapes
  • reads open with O_NOFOLLOW where available, then verify fd identity matches the path identity before returning the buffer or handle
  • writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
  • remove and mkdir use fd-relative syscalls on POSIX through a small Python helper, with a Node fallback when the helper cannot spawn
  • archive extraction stages into a private directory and merges through the same boundary checks used by direct writes

Limitations

  • Windows uses the safest Node-level behavior available; some fd-relative POSIX hardening is unavailable there.
  • 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

License

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Contributors