mrkeyoor.com_
Sat 08 Aug 22:01 UTC
npmSecurityupdated 08 Aug 2026

@openclaw/fs-safe

@openclaw/fs-safe wraps a trusted directory in a capability-style Root object whose read, write, create, copy, move, remove, list, and walk methods accept root-relative paths. It rejects traversal and adds defenses against symlink changes, hardlink aliases, and rename races that a `path.resolve(...).startsWith(...)` check misses. It also supplies atomic writes, bounded archive extraction, private temp workspaces, secure credential reads, JSON/file stores, and typed policy errors. Linux native opens can report kernel-atomic containment; other mechanisms report best-effort containment.

Verdict

A serious option when untrusted relative paths are unavoidable and Node's path-string API is too easy to misuse. Adopt it with `require` mode where race resistance is part of the threat model, and do not mistake its root boundary for OS isolation.

API stability2/5The Root interface and closed FsSafeError codes are thoughtfully typed, and focused subpath exports make intended contracts clear. The project is still pre-1.0 at 0.5.2, however, and its own migration guide records a substantial 0.5 change from a persistent Python worker to bundled native bindings, including renamed configuration functions and environment variables. Consumers should expect more movement and pin minor versions.
Docs5/5The README and fs-safe.io explain the threat model, say plainly that the library is not a sandbox, distinguish kernel-atomic from best-effort containment by mechanism, document defaults and failure semantics, and link dedicated pages for native helpers, durability, locks, migration, and security. Examples cover the major subpaths, while limitations call out Windows behavior, hardlink metadata, and missing content validation.
Maintenance4/5Version 0.5.2 was published on 2026-08-03, GitHub reports a push on 2026-08-05, and the unarchived repository has current CI plus only one open issue and PR combined. The release and source are clearly active. The score stops short of five because this is a young 0.x security-sensitive library with 53 stars, seven native target builds, and a large cross-platform behavior matrix to sustain.
Ecosystem3/5The package recorded 3,452,036 downloads in the measured week and integrates with Node FileHandle streams, async disposal, optional jszip and tar extraction, external writers, JSON stores, sidecar locks, and native binaries for seven targets. That is broad functionality inside one package, but Node 22 is the floor, the project has 53 GitHub stars, and there is not yet a visible third-party adapter or plugin ecosystem.

Use it if

  • Your Node service, agent, plugin host, upload handler, or CLI performs filesystem work using caller-controlled relative paths
  • You need one root-bound API covering reads and mutations instead of repeating fragile path-prefix checks around node:fs
  • You need policy-driven ZIP or TAR extraction with traversal checks plus entry, path-depth, compressed-size, and extracted-size budgets
  • You can require Node 22 and can make deployment policy depend on the reported kernel-atomic or best-effort containment class
Skip it if

Setup reality

Install with `npm install @openclaw/fs-safe` or the README's pnpm command. There are no required runtime or peer dependencies, but ZIP and TAR helpers use optional `jszip` and `tar` packages; installs that omit optional dependencies retain the non-archive APIs. Node 22 or newer is mandatory and the package is ESM-only with explicit subpath exports and bundled declarations. The npm tarball is large at 17,249,142 unpacked bytes because it includes native bindings for seven targets. There is no postinstall download or Rust build. Before the first filesystem call, choose native policy with `configureFsSafeNative({ mode: 'auto' | 'off' | 'require' })` or `FS_SAFE_NATIVE_MODE`. `auto` silently falls back to guarded JavaScript when a binding cannot load, while `require` fails closed and is the right setting when concurrent hostile mutation is in scope. Version 0.5 renamed the old Python helper API and environment variables; compatibility names warn once but should be migrated. `root()` expects its trusted directory to exist unless you deliberately call `ensureRoot()` for a root-resolving relative target. Reads default to a 16 MiB limit, writes replace by default, create refuses clobbering, and move refuses clobbering unless asked. Symlink and hardlink policies need an explicit threat-model decision. Check the `containment` result from open operations if platform strength affects authorization. Archive extraction needs caller-supplied timeout and sensible budgets. The library does not validate file contents, signatures, schemas, or archive payload meaning, and hardlink rejection is only defense in depth. Operational code must branch on `FsSafeError.code`, close returned file handles, and treat walking truncation or unreadable-directory markers as incomplete results rather than empty trees.

Patterns

Read and write beneath a trusted rootcreate-root-boundary

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace', {
  mkdir: true,
  symlinks: 'reject',
  hardlinks: 'reject',
  mode: 0o600,
});

await files.write('notes/today.txt', 'hello\n');
const text = await files.readText('notes/today.txt');

The root directory is trusted authority. Relative paths containing an escape such as ../outside are rejected.

Fail closed when the native binding is unavailablerequire-native-containment

import { configureFsSafeNative, root } from '@openclaw/fs-safe';

configureFsSafeNative({ mode: 'require' });
const files = await root('/srv/app/workspace');

Configure before first use. `auto` can fall back to best-effort JavaScript; `require` refuses to start without a supported binding.

Bound an untrusted file readread-with-size-limit

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/uploads');
const bytes = await files.readBytes('incoming/avatar.png', {
  maxBytes: 2 * 1024 * 1024,
  symlinks: 'reject',
  hardlinks: 'reject',
});

Root reads default to 16 MiB. Set a smaller workload-specific budget rather than reading first and checking length later.

Stream through a pinned file handlestream-large-file

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace');
await using opened = await files.open('logs/current.log');
console.log(opened.containment, opened.realPath);
for await (const chunk of opened.handle.createReadStream()) {
  await consume(chunk);
}

Use the returned handle for the operation. A separate exists or stat call cannot pin a later open to the same filesystem object.

Create a file only when absentcreate-without-clobber

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace', { mkdir: true });
await files.create('jobs/7.json', JSON.stringify({ state: 'queued' }), {
  mode: 0o600,
});

create throws `already-exists`; write replaces by default. Pick the method that expresses your clobber policy.

Protect sensitive paths inside the rootdeny-sensitive-mutations

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace', {
  denyMutations: {
    paths: ['/srv/app/workspace/.env'],
    prefixes: ['/srv/app/workspace/.ssh'],
  },
});
await files.write('output.txt', 'ok');

A blocked write throws `denied-path`. The deny list is an extra policy inside the root, not a replacement for OS permissions.

Copy an external upload into the boundarycopy-upload-in

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace', { mkdir: true });
await files.copyIn('uploads/avatar.png', '/var/tmp/upload-8127', {
  maxBytes: 5 * 1024 * 1024,
  sourceHardlinks: 'reject',
  mode: 0o600,
});

The source path is absolute and outside the root; the destination remains relative and bounded. Content validation is still your job.

Move a file without accidental clobberingmove-with-explicit-overwrite

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace');
await files.move('drafts/report.json', 'published/report.json');
// To replace deliberately:
await files.move('drafts/latest.json', 'published/latest.json', { overwrite: true });

move defaults to no clobber because replacement consumes the source as well as deleting the destination.

Walk a caller-selected subtree with limitswalk-with-budgets

import { root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace');
for await (const entry of files.walk('plugins', {
  maxDepth: 6,
  maxEntries: 10_000,
  symlinkPolicy: 'skip',
  limitBehavior: 'throw',
  onDirectoryError: 'throw',
})) {
  if (entry.kind === 'file') console.log(entry.relativePath);
}

Choosing truncate or skip-and-report requires handling marker entries, or an incomplete tree can look complete.

Extract an archive with explicit budgetsextract-bounded-archive

import { extractArchive, resolveArchiveKind } from '@openclaw/fs-safe/archive';

const kind = resolveArchiveKind(uploadPath);
if (!kind) throw new Error('Unsupported archive');
await extractArchive({
  archivePath: uploadPath,
  destDir: '/srv/app/workspace/plugin',
  kind,
  timeoutMs: 15_000,
  limits: {
    maxArchiveBytes: 64 * 1024 * 1024,
    maxEntries: 5_000,
    maxExtractedBytes: 128 * 1024 * 1024,
    maxEntryBytes: 32 * 1024 * 1024,
    maxEntryPathComponents: 32,
  },
});

ZIP and TAR helpers depend on optional jszip and tar packages. Extraction blocks path tricks but does not validate payload semantics or signatures.

Create and automatically clean a temp workspaceuse-private-temp-workspace

import { tempWorkspace } from '@openclaw/fs-safe/temp';

await using temp = await tempWorkspace({
  rootDir: '/var/tmp/my-app',
  prefix: 'render-',
  dirMode: 0o700,
  mode: 0o600,
});
await temp.writeJson('job.json', { id: 42 }, { trailingNewline: true });
const bytes = await temp.read('job.json');

Async disposal attempts identity-checked cleanup. Inspect explicit cleanup results when failure to remove temporary data must be reported.

Branch on typed filesystem policy failureshandle-policy-errors

import { FsSafeError, root } from '@openclaw/fs-safe';

const files = await root('/srv/app/workspace');
try {
  await files.write(userPath, payload);
} catch (error) {
  if (error instanceof FsSafeError && error.code === 'outside-workspace') {
    return { ok: false, reason: 'path rejected' };
  }
  throw error;
}

Policy and operational failures share FsSafeError but have different codes and categories. Do not turn disk failures into user-input errors.

Alternatives

PackageRegistryPick it when
write-file-atomicnpmYou only need atomic replacement of individual files and do not need a root containment boundary
tarnpmYou only need TAR creation or extraction and will own traversal, link, timeout, and size policy yourself
proper-lockfilenpmYour actual requirement is cross-process file locking rather than untrusted relative-path containment
fs-extranpmYou want convenient copy, mkdir, and remove helpers for trusted paths, with no claim of hostile-path safety