Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

### Security and Correctness

- **Security:** Resolve symlink aliases component-by-component before applying later `..` segments in the exported root-path validator, and validate raw `root()` inputs before normalized I/O, preventing alias-dependent traversal from being approved under a different lexical path.
- Prefer macOS 15.4's `O_RESOLVE_BENEATH` for native opens, retain the guarded component walk on older kernels, and apply an `F_GETPATH` post-open escape detector to both routes without claiming rename-race atomicity.
- Report open containment explicitly: native `openBeneath()` returns `{ fd, containment }` with `kernel-atomic` on Linux and `best-effort` on macOS/Windows, while JavaScript root open/read/writable results report `best-effort`.
- Serialize async `jsonStore` writes and read-modify-write updates in-process by canonical store path before taking the cross-process sidecar lock, preventing overlapping `write`, `update`, and `updateOr` calls from silently losing updates; reject nested same-path mutations with typed `store-reentrant-update` errors. Thanks @yetval for reporting this.
- Create `append`, `openWritable`, and fallback `copyIn` parents through guarded per-component walks and continue I/O through the resolved in-root parent, preventing symlink-swap races from creating directories outside the root while preserving valid in-root symlink parents. Thanks @yetval for reporting this.
- Add pinned-destination hardlink rejection and bounded original-content restoration to `replaceFileAtomic()` and its sync variant, including typed `restored` / `restore-failed` receipts for torn copy-fallback writes.
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

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

Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/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 refuses to escape — through `..`, symlink swaps, hardlink aliases, or TOCTOU rename races between check and use.
Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/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 native opens are kernel-atomic; macOS, Windows, and JavaScript paths are best-effort.

```ts
import { root } from "@openclaw/fs-safe";
Expand Down Expand Up @@ -45,11 +45,11 @@ The same idea has landed in other languages. Go [added `os.Root` and `OpenInRoot
| `path.resolve().startsWith()` | string check only | – | – | – | – |
| [`write-file-atomic`](https://www.npmjs.com/package/write-file-atomic) | – | ✓ | – | – | – |
| Go [`os.Root`](https://go.dev/blog/osroot) / Rust [`cap-std`](https://github.com/bytecodealliance/cap-std) | ✓ | platform | ✓ | ✓ | – |
| **`@openclaw/fs-safe`** | **✓** | **✓** | **✓** | **✓ (POSIX fd-relative)** | **✓ (ZIP/TAR; native zstd/bzip2)** |
| **`@openclaw/fs-safe`** | **✓** | **✓** | **✓** | **Linux atomic; others best-effort** | **✓ (ZIP/TAR; native 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.
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](docs/security-model.md) describes the exact Linux, macOS, Windows, and JavaScript fallback guarantees and race boundaries.

## Install

Expand Down Expand Up @@ -79,6 +79,10 @@ helper policy](docs/native-helper.md) for the exact boundary and deployment
tradeoff, and [native architecture](docs/native.md) for the platform mechanisms
and policy ownership model.

Open results report the mechanism's containment class as `"kernel-atomic"` or
`"best-effort"`. Linux native `openBeneath()` is kernel-atomic; macOS, Windows,
and guarded JavaScript results are best-effort. See the [security model](docs/security-model.md#containment-guarantees-by-platform) before using that fact in higher-level policy.

## Migrating from the Python helper

Version 0.5 replaces the persistent Python worker with optional prebuilt native
Expand Down Expand Up @@ -184,7 +188,7 @@ const locked = await root("/srv/workspace", {
await locked.write(".env", "token"); // FsSafeError code "denied-path"
```

`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.
`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 operation-local identity checks, and inspect `containment` when the platform distinction matters.

## Subpaths

Expand Down
2 changes: 1 addition & 1 deletion docs/archive.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and

- **Path traversal:** entries with `..`, absolute paths, or Windows drive prefixes are rejected (`ArchiveSecurityError`).
- **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. A symlink swap in the destination tree mid-merge is caught.
- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
- **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
- **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
- **Metadata bombs:** a fixed-header pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 size fields without interpreting metadata content.
Expand Down
7 changes: 6 additions & 1 deletion docs/native-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ layer owns policy, retries, filters, budgets, modes, cleanup, error
normalization, and the decision to fall back.

- Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS` and `renameat2(RENAME_NOREPLACE)`.
- macOS resolves components with `O_NOFOLLOW`, restarts in-root symlinks from the pinned root descriptor, and uses `renameatx_np(RENAME_EXCL)`.
- macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. No-replace publication uses `renameatx_np(RENAME_EXCL)`.
- Windows uses handle-relative `NtCreateFile`, rejects reparse points, and uses `FileRenameInfoEx` with replacement disabled.

Native primitives back create-only pinned writes, async sidecar creation,
Expand All @@ -45,6 +45,11 @@ Equivalent JavaScript paths remain available for documented fallback-capable
features. See [Native architecture](native.md#javascript-fallback-guarantees-and-delta)
for the exact difference.

`openBeneath()` returns `{ fd, containment }`. `containment` is
`"kernel-atomic"` for Linux `openat2` and `"best-effort"` for macOS and
Windows. Public JavaScript root open/read/writable results also expose the
field and report `"best-effort"`; the label reports mechanism, not policy.

## Migration from the Python helper

Version 0.5 removes the Python worker and interpreter-path selection. The mode
Expand Down
9 changes: 6 additions & 3 deletions docs/native.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.

- Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative
`mkdirat`/`linkat`/`renameat2`, `FICLONE`, and `copy_file_range`.
- macOS walks components with `openat(O_NOFOLLOW)`, restarts in-root symlinks
from the pinned root, uses `renameatx_np(RENAME_EXCL)`, and permits
- macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk
components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the
pinned root. Both routes apply an `F_GETPATH` post-open containment detector,
but directory rename races mean the result remains `best-effort`, not
race-atomic. macOS uses `renameatx_np(RENAME_EXCL)` and permits
`fclonefileat` in an owned, non-shared parent. The clone is normalized inside
a private staging directory: flags, ACLs, extended attributes, and broad mode
bits are cleared before no-replace publication.
Expand Down Expand Up @@ -94,7 +97,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability:

| Capability | Native path | Guarded JavaScript path |
|---|---|---|
| Root-relative opens/mutations | Descriptor-relative beneath operations; Linux uses `openat2`, Windows rejects reparse traversal in the object-manager call. | Lexical + canonical checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A hostile same-UID peer has a wider pathname race window. |
| Root-relative opens/mutations | Descriptor-relative beneath operations. Linux reports `kernel-atomic`; macOS and Windows report `best-effort`. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A hostile same-UID peer has a wider pathname race window. |
| ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | JSZip/node-tar into a private stage, then the same guarded merge policy. |
| Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
| Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const names = await fs.list("state"); // string[]
const entries = await fs.list("state", { withFileTypes: true }); // DirEntry[]
```

`exists`, `stat`, and `list` are boundary-checked but **do not pin a later operation** to the same filesystem object. For race-resistant reads or writes, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` — they pin the path identity at the point of use.
`exists`, `stat`, and `list` are boundary-checked but **do not pin a later operation** to the same filesystem object. For operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. Linux native beneath opens are kernel-atomic; other mechanisms remain best-effort as documented in the [security model](security-model.md#containment-guarantees-by-platform).

## 6. Catch escapes

Expand Down
8 changes: 4 additions & 4 deletions docs/reading.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The `Root` handle exposes five read shapes. Pick the narrowest one that gives you what you need — narrower shapes do less work and surface fewer footguns.

```ts
const result = await fs.read("notes/today.txt"); // { buffer, realPath, stat }
const result = await fs.read("notes/today.txt"); // { buffer, containment, realPath, stat }
const text = await fs.readText("notes/today.txt"); // string
const bytes = await fs.readBytes("image.png"); // Buffer
const json = await fs.readJson<Config>("config.json"); // T
Expand All @@ -30,7 +30,7 @@ Regardless of shape, every read goes through the same boundary checks:
The full result. Use it when you need both the bytes and the verified `realPath` or `stat`:

```ts
const { buffer, realPath, stat } = await fs.read("notes/today.txt");
const { buffer, containment, realPath, stat } = await fs.read("notes/today.txt");
console.log(`${stat.size} bytes at ${realPath}`);
```

Expand Down Expand Up @@ -63,7 +63,7 @@ For tighter control over malformed-or-missing JSON, use the standalone helpers i

### `fs.open(rel, options?)`

Returns a `FileHandle` plus the verified `realPath` and `stat`. Use this for streaming or partial reads, and **always close the handle**:
Returns a `FileHandle` plus `containment: "best-effort"`, the verified `realPath`, and `stat`. Use this for streaming or partial reads, and **always close the handle**:

```ts
const opened = await fs.open("large.log");
Expand Down Expand Up @@ -121,7 +121,7 @@ if (await fs.exists("notes/today.txt")) {
}
```

A symlink swap between `exists` and `readText` is caught by the read; the boundary is per-call.
A symlink swap between `exists` and `readText` is checked again by the read; the boundary and its documented race window are per-call.

## Streaming patterns

Expand Down
8 changes: 6 additions & 2 deletions docs/root.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ Every method on the returned handle accepts paths relative to the root and rejec
### Reads

```ts
fs.read(rel, options?) // { buffer, realPath, stat }
fs.read(rel, options?) // { buffer, containment, realPath, stat }
fs.readBytes(rel, options?) // Buffer
fs.readText(rel, options?) // string
fs.readJson<T>(rel, options?) // parsed T
fs.open(rel, options?) // { handle, realPath, stat, [Symbol.asyncDispose] }
fs.open(rel, options?) // { handle, containment, realPath, stat, [Symbol.asyncDispose] }
fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
fs.reader(options?) // (path) => Promise<Buffer>; useful for loader APIs
fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kind, size }>
Expand Down Expand Up @@ -79,6 +79,10 @@ await using opened = await fs.open("large.log");
}
```

`open()`, `read()`, and `openWritable()` results include
`containment: "best-effort"`. The field reports the mechanism used; see the
[security model](security-model.md#containment-guarantees-by-platform).

### Writes

```ts
Expand Down
Loading
Loading