@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.
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.
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
- You need a security sandbox against hostile code: the README explicitly says this is a library guardrail and does not replace containers, seccomp, AppArmor, permissions, or process isolation
- Your runtime is Node 20 or older: version 0.5.2 declares Node >=22 and uses an ESM-only export map
- A 17,249,142-byte unpacked package is disproportionate to your problem: seven prebuilt native bindings ship together even if your deployment uses only one platform
- You need identical race guarantees on every platform: only Linux native openBeneath operations are reported kernel-atomic, while macOS, Windows, and guarded JavaScript paths are best-effort
- You require a settled major-version contract: the package is still at 0.5.2, and 0.5 replaced a persistent Python helper with native bindings plus renamed configuration and environment variables
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
| Package | Registry | Pick it when |
|---|---|---|
| write-file-atomic | npm | You only need atomic replacement of individual files and do not need a root containment boundary |
| tar | npm | You only need TAR creation or extraction and will own traversal, link, timeout, and size policy yourself |
| proper-lockfile | npm | Your actual requirement is cross-process file locking rather than untrusted relative-path containment |
| fs-extra | npm | You want convenient copy, mkdir, and remove helpers for trusted paths, with no claim of hostile-path safety |