@openclaw/fs-safe 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +58 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +25 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +168 -0
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +122 -125
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-file-store.d.ts +5 -7
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +21 -34
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-context.d.ts +23 -0
- package/dist/root-context.d.ts.map +1 -0
- package/dist/root-context.js +62 -0
- package/dist/root-errors.d.ts +4 -0
- package/dist/root-errors.d.ts.map +1 -0
- package/dist/root-errors.js +21 -0
- package/dist/root-impl.d.ts +108 -0
- package/dist/root-impl.d.ts.map +1 -0
- package/dist/{safe-root.js → root-impl.js} +484 -265
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +6 -1
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +110 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +191 -0
- package/docs/config.md +94 -0
- package/docs/contributing.md +80 -0
- package/docs/errors.md +154 -0
- package/docs/file-store.md +213 -0
- package/docs/filename.md +0 -0
- package/docs/index.md +77 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +204 -0
- package/docs/json.md +192 -0
- package/docs/local-roots.md +164 -0
- package/docs/output.md +92 -0
- package/docs/path-scope.md +130 -0
- package/docs/path.md +191 -0
- package/docs/permissions.md +95 -0
- package/docs/private-file-store.md +45 -0
- package/docs/python-helper.md +103 -0
- package/docs/quickstart.md +147 -0
- package/docs/reading.md +174 -0
- package/docs/regular-file.md +175 -0
- package/docs/root.md +205 -0
- package/docs/secret-file.md +154 -0
- package/docs/secure-file.md +82 -0
- package/docs/security-model.md +99 -0
- package/docs/sidecar-lock.md +230 -0
- package/docs/store.md +77 -0
- package/docs/temp.md +284 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +178 -0
- package/docs/timing.md +121 -0
- package/docs/types.md +155 -0
- package/docs/walk.md +63 -0
- package/docs/writing.md +222 -0
- package/package.json +10 -2
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- package/dist/sibling-temp-write.js +0 -40
package/docs/config.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Config
|
|
3
|
+
description: "Process-global defaults for optional fs-safe helpers."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `@openclaw/fs-safe/config`
|
|
7
|
+
|
|
8
|
+
Process-global configuration knobs for optional fs-safe helpers. The Python helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
configureFsSafePython,
|
|
13
|
+
configureFsSafeLocks,
|
|
14
|
+
getFsSafePythonConfig,
|
|
15
|
+
getFsSafeLockConfig,
|
|
16
|
+
type FsSafeLockConfig,
|
|
17
|
+
type FsSafePythonConfig,
|
|
18
|
+
type FsSafePythonMode,
|
|
19
|
+
} from "@openclaw/fs-safe/config";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
These functions are also re-exported from the main entry point. Prefer the subpath when you only need helper configuration and want the smallest import surface.
|
|
23
|
+
|
|
24
|
+
## `configureFsSafePython(config)`
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
function configureFsSafePython(config: Partial<FsSafePythonConfig>): void;
|
|
28
|
+
|
|
29
|
+
type FsSafePythonConfig = {
|
|
30
|
+
mode: FsSafePythonMode;
|
|
31
|
+
pythonPath?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type FsSafePythonMode = "auto" | "off" | "require";
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Set the process-global policy. Calls merge into the existing override config, so passing `{ pythonPath: "/usr/bin/python3" }` keeps any previously set `mode`. Configure once at startup, before the first `root()` call — switching modes mid-process is supported but the helper may already be running.
|
|
38
|
+
|
|
39
|
+
| Mode | Behavior |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `auto` | Default. Use the helper when it starts; fall back to Node-only behavior if Python is missing or fails to start. |
|
|
42
|
+
| `off` | Never spawn the helper. Read/write/move use Node fallbacks plus pre/post identity checks. |
|
|
43
|
+
| `require` | Fail closed if the helper cannot start. Operations that need the helper raise `FsSafeError("helper-unavailable")`. |
|
|
44
|
+
|
|
45
|
+
## `getFsSafePythonConfig()`
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
function getFsSafePythonConfig(): FsSafePythonConfig;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Return the effective configuration: programmatic overrides win, then env vars, then the package default (`auto`).
|
|
52
|
+
|
|
53
|
+
## `configureFsSafeLocks(config)`
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
|
|
57
|
+
|
|
58
|
+
type FsSafeLockConfig = {
|
|
59
|
+
staleRecovery: "fail-closed";
|
|
60
|
+
staleMs?: number;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
retry?: FileLockRetryOptions;
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
|
|
67
|
+
|
|
68
|
+
`staleRecovery` currently supports `"fail-closed"` only. Stale third-party sidecars are not deleted by path because Node cannot atomically bind that deletion to the file that was inspected.
|
|
69
|
+
|
|
70
|
+
## `getFsSafeLockConfig()`
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
function getFsSafeLockConfig(): FsSafeLockConfig;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Return the current sidecar lock defaults.
|
|
77
|
+
|
|
78
|
+
## Environment variables
|
|
79
|
+
|
|
80
|
+
The same policy can be set without code:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
FS_SAFE_PYTHON_MODE=auto # auto | off | require | true | false | on | off | 1 | 0 | never | required
|
|
84
|
+
FS_SAFE_PYTHON=/usr/bin/python3
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
OpenClaw compatibility aliases are accepted: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`, `OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`. Programmatic overrides via `configureFsSafePython` always win.
|
|
88
|
+
|
|
89
|
+
## Related pages
|
|
90
|
+
|
|
91
|
+
- [Python helper policy](python-helper.md) — when to pick `auto`, `off`, or `require`, and what each mode protects.
|
|
92
|
+
- [File lock](sidecar-lock.md) — the per-resource lock API that consumes lock defaults.
|
|
93
|
+
- [Root API](root.md) — the API whose POSIX hardening the helper backs.
|
|
94
|
+
- [Errors](errors.md) — `helper-unavailable` and `helper-failed`.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
The `fs-safe` repo lives at [github.com/openclaw/fs-safe](https://github.com/openclaw/fs-safe). Contributions welcome — issues, bug reports, focused PRs.
|
|
4
|
+
|
|
5
|
+
## Local setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
git clone https://github.com/openclaw/fs-safe.git
|
|
9
|
+
cd fs-safe
|
|
10
|
+
pnpm install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node 20.11 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
|
|
14
|
+
|
|
15
|
+
## Build
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm build
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Runs `tsc -p tsconfig.json`. Output lands in `dist/`. The package's `prepack` hook re-runs the build before publishing — manual `pnpm build` is only required when you want to inspect the output or run a freshly-built copy locally.
|
|
22
|
+
|
|
23
|
+
## Test
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm test
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Vitest. Tests live in `test/` and follow `*.test.ts`. Run a single file with:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm test test/archive.test.ts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
|
|
36
|
+
|
|
37
|
+
## Format and types
|
|
38
|
+
|
|
39
|
+
The repo doesn't ship a separate lint config; `tsc --noEmit` (run by `pnpm build`) is the type gate. Format with your editor's TypeScript Language Server defaults — keep diffs tight.
|
|
40
|
+
|
|
41
|
+
## Docs
|
|
42
|
+
|
|
43
|
+
The docs site is rendered from `docs/*.md` by `scripts/build-docs-site.mjs`. Build locally to preview:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
node scripts/build-docs-site.mjs
|
|
47
|
+
open dist/docs-site/index.html
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing.
|
|
51
|
+
|
|
52
|
+
Adding a new doc page:
|
|
53
|
+
|
|
54
|
+
1. Create `docs/<page>.md`. Use a leading `# Title` heading.
|
|
55
|
+
2. Add the page to the appropriate section in `scripts/build-docs-site.mjs` (`sections` array near the top).
|
|
56
|
+
3. Cross-link from `docs/index.md` if it's a major surface.
|
|
57
|
+
4. Re-run the local build.
|
|
58
|
+
|
|
59
|
+
Internal links use relative `*.md` paths — the builder rewrites them to the rendered HTML. Code fences support GitHub-flavored markdown.
|
|
60
|
+
|
|
61
|
+
## PRs
|
|
62
|
+
|
|
63
|
+
Small, focused PRs land faster. The general shape:
|
|
64
|
+
|
|
65
|
+
- One concern per PR. Bug fixes separate from new APIs.
|
|
66
|
+
- A regression test for every bug fix where the test framework can express it.
|
|
67
|
+
- A changelog entry under `## Unreleased` when behavior visibly changes.
|
|
68
|
+
- For new public APIs: a docs page in `docs/` plus a sidebar entry.
|
|
69
|
+
|
|
70
|
+
## Releases
|
|
71
|
+
|
|
72
|
+
Release process lives in the maintainer's runbook. External contributors don't need to do anything beyond getting the PR merged.
|
|
73
|
+
|
|
74
|
+
## Reporting security issues
|
|
75
|
+
|
|
76
|
+
Suspected security issues belong in private disclosure first. See [`SECURITY.md`](https://github.com/openclaw/fs-safe/blob/main/SECURITY.md) in the repo for the current contact path. Don't open a public issue for a credential-stealing or sandbox-escape bug — coordinate the disclosure first.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
By contributing you agree that your contributions are licensed under the project's [MIT license](https://github.com/openclaw/fs-safe/blob/main/LICENSE).
|
package/docs/errors.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Shape
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
class FsSafeError extends Error {
|
|
13
|
+
readonly name: "FsSafeError";
|
|
14
|
+
readonly code: FsSafeErrorCode;
|
|
15
|
+
readonly category: "policy" | "operational";
|
|
16
|
+
|
|
17
|
+
constructor(code: FsSafeErrorCode, message: string, options?: { cause?: unknown });
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`cause` is available through the standard `Error` `cause` property when the failure was triggered by a `NodeJS.ErrnoException` (e.g. a wrapped `EACCES`). Inspect it for the original `code` / `errno` / `syscall` if you need finer-grained reporting.
|
|
22
|
+
|
|
23
|
+
`category` separates caller-policy failures from operational failures:
|
|
24
|
+
|
|
25
|
+
- `"policy"` — unsafe input or target state, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
|
|
26
|
+
- `"operational"` — environment/runtime failures, such as helper startup, platform support, timeout, or unverifiable permissions.
|
|
27
|
+
|
|
28
|
+
## Code union
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
type FsSafeErrorCode =
|
|
32
|
+
| "already-exists"
|
|
33
|
+
| "hardlink"
|
|
34
|
+
| "helper-failed"
|
|
35
|
+
| "helper-unavailable"
|
|
36
|
+
| "insecure-permissions"
|
|
37
|
+
| "invalid-path"
|
|
38
|
+
| "not-empty"
|
|
39
|
+
| "not-file"
|
|
40
|
+
| "not-found"
|
|
41
|
+
| "not-owned"
|
|
42
|
+
| "not-removable"
|
|
43
|
+
| "outside-workspace"
|
|
44
|
+
| "path-alias"
|
|
45
|
+
| "path-mismatch"
|
|
46
|
+
| "permission-unverified"
|
|
47
|
+
| "symlink"
|
|
48
|
+
| "timeout"
|
|
49
|
+
| "too-large"
|
|
50
|
+
| "unsupported-platform";
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Code reference
|
|
54
|
+
|
|
55
|
+
| Code | When it fires | Common causes |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. |
|
|
58
|
+
| `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
|
|
59
|
+
| `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
|
|
60
|
+
| `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. |
|
|
61
|
+
| `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
|
|
62
|
+
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
|
|
63
|
+
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
|
|
64
|
+
| `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
|
|
65
|
+
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
|
|
66
|
+
| `not-owned` | A secure file owner check failed. | File is owned by another UID. |
|
|
67
|
+
| `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit. |
|
|
68
|
+
| `outside-workspace` | Path resolves outside the configured root. | `..` traversal; absolute path outside the root; symlink resolved out. |
|
|
69
|
+
| `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
|
|
70
|
+
| `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
|
|
71
|
+
| `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
|
|
72
|
+
| `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
|
|
73
|
+
| `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
|
|
74
|
+
| `too-large` | Read exceeded `maxBytes`. | Caller gave a too-permissive file or didn't size-cap correctly. |
|
|
75
|
+
| `unsupported-platform` | The requested operation is not supported on the current platform. | E.g. POSIX-only helper invoked on Windows. |
|
|
76
|
+
|
|
77
|
+
## Branching
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { FsSafeError } from "@openclaw/fs-safe";
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await fs.write("../escape.txt", "x");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!(err instanceof FsSafeError)) throw err;
|
|
86
|
+
switch (err.code) {
|
|
87
|
+
case "outside-workspace":
|
|
88
|
+
return reply(400, "path escapes workspace");
|
|
89
|
+
case "already-exists":
|
|
90
|
+
return reply(409, "exists");
|
|
91
|
+
case "too-large":
|
|
92
|
+
return reply(413, "too large");
|
|
93
|
+
case "not-found":
|
|
94
|
+
return reply(404, "missing");
|
|
95
|
+
case "symlink":
|
|
96
|
+
case "hardlink":
|
|
97
|
+
case "path-mismatch":
|
|
98
|
+
case "path-alias":
|
|
99
|
+
return reply(400, "unsafe path");
|
|
100
|
+
default:
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The compiler will flag missing cases when you exhaust the union — keep your switch up-to-date as the library adds new codes.
|
|
107
|
+
|
|
108
|
+
## Distinguishing from `NodeJS.ErrnoException`
|
|
109
|
+
|
|
110
|
+
Some failures bubble up as native Node errors (e.g. `EACCES`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { FsSafeError } from "@openclaw/fs-safe";
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await op();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof FsSafeError) {
|
|
119
|
+
handleFsSafe(err);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if ((err as NodeJS.ErrnoException).code === "EACCES") {
|
|
123
|
+
handleAccess();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A common pattern is to wrap your domain code in a single try/catch that maps both shapes to your application's typed error format.
|
|
131
|
+
|
|
132
|
+
## Specialty errors
|
|
133
|
+
|
|
134
|
+
A handful of helpers throw their own typed errors instead of `FsSafeError`:
|
|
135
|
+
|
|
136
|
+
- `JsonFileReadError` — thrown by [`readJson`](json.md). Carries `cause` so you can distinguish missing (`ENOENT`) from invalid (`SyntaxError`).
|
|
137
|
+
- `ArchiveLimitError` — thrown by [`extractArchive`](archive.md) when an archive size, entry count, or extracted-byte budget is exceeded. The `code` field uses `ARCHIVE_LIMIT_ERROR_CODE` constants (e.g. `"ARCHIVE_SIZE_EXCEEDS_LIMIT"`).
|
|
138
|
+
- `ArchiveSecurityError` — thrown by extraction when an entry path violates safety rules (traversal, drive prefix, blocked link type). The `code` field uses `ArchiveSecurityErrorCode` values.
|
|
139
|
+
|
|
140
|
+
These are exported from their respective subpaths.
|
|
141
|
+
|
|
142
|
+
## Why `FsSafeError`?
|
|
143
|
+
|
|
144
|
+
Two reasons it isn't a richer hierarchy of subclasses:
|
|
145
|
+
|
|
146
|
+
1. **Switch on `code`, don't `instanceof` a tree.** `code` is a closed string union the TypeScript compiler can exhaust-check. Subclasses make `instanceof` ladders that drift over time.
|
|
147
|
+
2. **One catch handler.** Library callers often want a single "is this an `fs-safe` failure?" gate before deciding what to do — `instanceof FsSafeError` plus a switch is the cleanest expression of that.
|
|
148
|
+
|
|
149
|
+
## See also
|
|
150
|
+
|
|
151
|
+
- [`root()`](root.md) — every method documents the codes it can throw.
|
|
152
|
+
- [Reading](reading.md) — read-path codes.
|
|
153
|
+
- [Writing](writing.md) — write-path codes.
|
|
154
|
+
- [Archive extraction](archive.md) — `ArchiveLimitError` and `ArchiveSecurityError`.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# File store
|
|
2
|
+
|
|
3
|
+
`fileStore` is exported from `@openclaw/fs-safe/store`. It is a managed wrapper around `root()` for the common "store files under a directory at known modes, prune old ones, hand back absolute paths" pattern. Useful for caches, ingest staging, generated artifacts, anywhere the consumer wants object-style access plus stream and copy primitives.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
fileStore,
|
|
8
|
+
type FileStore,
|
|
9
|
+
type FileStoreOptions,
|
|
10
|
+
type FileStoreWriteOptions,
|
|
11
|
+
type FileStorePruneOptions,
|
|
12
|
+
} from "@openclaw/fs-safe/store";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## When to reach for it
|
|
16
|
+
|
|
17
|
+
- You want a single directory holding files written by your code, with consistent mode bits and atomic placement.
|
|
18
|
+
- You want a `FileStore.write(rel, data)` / `read(rel)` / `pruneExpired(...)` interface.
|
|
19
|
+
- You want to feed a stream into the store with a byte cap.
|
|
20
|
+
- You don't need the full `Root` surface (move, list, mkdir, …); the store can hand you a real `Root` via `.root()` when you do.
|
|
21
|
+
|
|
22
|
+
## Factory: `fileStore(options)`
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const cache = fileStore({
|
|
26
|
+
rootDir: "/var/cache/app",
|
|
27
|
+
mode: 0o600, // file mode for writes (default 0o600)
|
|
28
|
+
dirMode: 0o700, // mode for parent directories created on demand (default 0o700)
|
|
29
|
+
maxBytes: 64 * 1024 * 1024, // optional: refuse writes/reads larger than this
|
|
30
|
+
private: true, // use secret-file atomic writes for private state
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Use `private: true` for credentials, auth profiles, tokens, and other private
|
|
35
|
+
state. Private mode keeps the same `FileStore` shape but routes writes through
|
|
36
|
+
the secret-file atomic path, refusing symlink parent components and re-asserting
|
|
37
|
+
mode after rename.
|
|
38
|
+
|
|
39
|
+
Returns a `FileStore`:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
type FileStore = {
|
|
43
|
+
readonly rootDir: string;
|
|
44
|
+
path(relativePath: string): string;
|
|
45
|
+
root(): Promise<Root>;
|
|
46
|
+
write(rel, data: string | Buffer, options?): Promise<string>;
|
|
47
|
+
writeStream(rel, stream: Readable, options?): Promise<string>;
|
|
48
|
+
copyIn(rel, sourcePath: string, options?): Promise<string>;
|
|
49
|
+
open(rel, options?): Promise<OpenResult>;
|
|
50
|
+
read(rel, options?): Promise<ReadResult>;
|
|
51
|
+
readBytes(rel, options?): Promise<Buffer>;
|
|
52
|
+
readText(rel, options?): Promise<string>;
|
|
53
|
+
readTextIfExists(rel, options?): Promise<string | null>;
|
|
54
|
+
readJson<T = unknown>(rel, options?): Promise<T>;
|
|
55
|
+
readJsonIfExists<T = unknown>(rel, options?): Promise<T | null>;
|
|
56
|
+
writeText(rel, data: string | Uint8Array, options?): Promise<string>;
|
|
57
|
+
writeJson(rel, data: unknown, options?): Promise<string>;
|
|
58
|
+
json<T = unknown>(rel, options?): JsonStore<T>;
|
|
59
|
+
remove(rel): Promise<void>;
|
|
60
|
+
exists(rel): Promise<boolean>;
|
|
61
|
+
pruneExpired(options: FileStorePruneOptions): Promise<void>;
|
|
62
|
+
};
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`path()` returns the absolute path the store would use, after asserting it stays inside `rootDir`. Useful for logging or for handing to other libraries.
|
|
66
|
+
|
|
67
|
+
`root()` returns a [`Root`](root.md) handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
|
|
68
|
+
|
|
69
|
+
## Writes
|
|
70
|
+
|
|
71
|
+
Every write goes through `writeSiblingTempFile` — temp + rename, mode applied to file and parent dir, both `fsync`'d.
|
|
72
|
+
|
|
73
|
+
### `write(rel, data, options?)`
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const path = await cache.write("entries/2026/05/05.json", JSON.stringify(entry));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Buffer or string. Returns the final absolute path. Throws `too-large` if `data.byteLength` exceeds `maxBytes`.
|
|
80
|
+
|
|
81
|
+
### `writeText(rel, data, options?)` / `writeJson(rel, data, options?)`
|
|
82
|
+
|
|
83
|
+
Convenience wrappers over `write`. `writeJson` pretty-prints with a trailing newline by default and accepts `{ trailingNewline: false }` when the exact bytes matter.
|
|
84
|
+
|
|
85
|
+
### `json<T>(rel, options?)`
|
|
86
|
+
|
|
87
|
+
Returns a typed single-file JSON state helper for a file under this store. It
|
|
88
|
+
inherits the store's root, mode, max-size, and private-write policy, then adds
|
|
89
|
+
`readOr`, `readRequired`, `update`, `updateOr`, and optional sidecar locking:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const state = cache.json<State>("state/settings.json", { lock: true });
|
|
93
|
+
await state.updateOr(defaultState, (current) => ({ ...current, enabled: true }));
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use this when one JSON file owns one piece of state. `jsonStore({ filePath })`
|
|
97
|
+
is the absolute-path convenience wrapper for the same primitive.
|
|
98
|
+
|
|
99
|
+
### `writeStream(rel, stream, options?)`
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { Readable } from "node:stream";
|
|
103
|
+
const path = await cache.writeStream("downloads/blob.bin", Readable.from(remoteFetch));
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Streams into a sibling temp with a running byte budget. Aborts the source stream with `too-large` if `maxBytes` is exceeded mid-stream — partial writes are cleaned up.
|
|
107
|
+
|
|
108
|
+
### `copyIn(rel, sourcePath, options?)`
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const path = await cache.copyIn("ingest/upload.bin", "/tmp/upload.bin");
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
One-shot ingest from an absolute source path. Source is checked for symlink/non-regular before copy. Same mode rules as `write`.
|
|
115
|
+
|
|
116
|
+
### `FileStoreWriteOptions`
|
|
117
|
+
|
|
118
|
+
Per-call overrides for the store-level defaults:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
type FileStoreWriteOptions = {
|
|
122
|
+
dirMode?: number;
|
|
123
|
+
mode?: number;
|
|
124
|
+
maxBytes?: number;
|
|
125
|
+
tempPrefix?: string; // override the default "." + basename
|
|
126
|
+
};
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Reads
|
|
130
|
+
|
|
131
|
+
`open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
|
|
132
|
+
|
|
133
|
+
## `remove(rel)` / `exists(rel)`
|
|
134
|
+
|
|
135
|
+
Forward to the underlying `Root`. `remove` unlinks files and `rmdir`s empty directories; non-empty dirs throw `not-empty`.
|
|
136
|
+
|
|
137
|
+
## `pruneExpired(options)`
|
|
138
|
+
|
|
139
|
+
Walk the store and delete files older than `options.ttlMs`:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
await cache.pruneExpired({
|
|
143
|
+
ttlMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
144
|
+
recursive: true,
|
|
145
|
+
pruneEmptyDirs: true,
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Options:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
type FileStorePruneOptions = {
|
|
153
|
+
ttlMs: number;
|
|
154
|
+
recursive?: boolean; // default false (top-level only)
|
|
155
|
+
maxDepth?: number; // bound recursion explicitly
|
|
156
|
+
pruneEmptyDirs?: boolean; // also remove dirs that became empty (only with recursive/maxDepth)
|
|
157
|
+
};
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Symlinks are skipped. The walk is best-effort — failures on individual entries don't abort the whole prune. Compares against `mtimeMs`.
|
|
161
|
+
|
|
162
|
+
## Difference from `Root`
|
|
163
|
+
|
|
164
|
+
| `FileStore` | `Root` |
|
|
165
|
+
|---|---|
|
|
166
|
+
| Object-style with mode+dirMode baked in. | Method-style boundary; mode is per-call or per-default. |
|
|
167
|
+
| `writeStream` with built-in byte budget. | Manual via `openWritable()`. |
|
|
168
|
+
| `writeText` / `writeJson` return the final absolute path. | `Root.write` / `writeJson` return void. |
|
|
169
|
+
| `copyIn` returns the final absolute path. | `Root.copyIn` returns void. |
|
|
170
|
+
| `pruneExpired` walks by `mtime`. | No prune helper. |
|
|
171
|
+
| Reads delegate via `Root` internally. | The boundary itself. |
|
|
172
|
+
|
|
173
|
+
If you need richer ops (move, list, append, mkdir), call `store.root()` to get a `Root` and use that.
|
|
174
|
+
|
|
175
|
+
## Common patterns
|
|
176
|
+
|
|
177
|
+
### Cache with TTL prune
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const cache = fileStore({ rootDir: "/var/cache/app", maxBytes: 16 * 1024 * 1024 });
|
|
181
|
+
|
|
182
|
+
await cache.writeStream(`${id}.bin`, fetchStream(id));
|
|
183
|
+
|
|
184
|
+
// Background prune every hour
|
|
185
|
+
setInterval(() => cache.pruneExpired({ ttlMs: 24 * 60 * 60 * 1000 }), 60 * 60 * 1000);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Ingest pipeline
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const ingest = fileStore({ rootDir: "/srv/ingest", mode: 0o644 });
|
|
192
|
+
|
|
193
|
+
for (const upload of uploads) {
|
|
194
|
+
const dest = await ingest.copyIn(`raw/${upload.id}`, upload.tempPath, {
|
|
195
|
+
maxBytes: 200 * 1024 * 1024,
|
|
196
|
+
});
|
|
197
|
+
await enqueueProcess(dest);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Drop down to `Root` for moves
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const root = await store.root();
|
|
205
|
+
await root.move(`pending/${id}`, `done/${id}`);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## See also
|
|
209
|
+
|
|
210
|
+
- [`root()`](root.md) — the boundary `FileStore` is built on; reach for it when you need move/list/append.
|
|
211
|
+
- [JSON store](json-store.md) — the JSON-state-file equivalent of this surface.
|
|
212
|
+
- [Atomic writes](atomic.md) — `writeSiblingTempFile` is what every write goes through.
|
|
213
|
+
- [Temp workspaces](temp.md) — private scratch directories backed by `FileStore`.
|
package/docs/filename.md
ADDED
|
Binary file
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Overview
|
|
3
|
+
permalink: /
|
|
4
|
+
description: "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# fs-safe
|
|
8
|
+
|
|
9
|
+
Trusted Node.js code that has to touch caller-controlled paths inside a directory it owns gets one boundary it can rely on. `root()` returns a capability-style handle that resolves every relative path against a real directory, refuses anything that escapes it, pins the file you opened, and verifies the write landed where you intended.
|
|
10
|
+
|
|
11
|
+
Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), but for Node. `root()` is the product; everything else in this doc set — JSON stores, atomic writes, secret files, archive extraction, temp workspaces — is supporting cast for the same boundary.
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
`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.
|
|
16
|
+
|
|
17
|
+
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. Typical fits: agent runtimes, plugin systems, upload extraction, local workspaces, CLIs — anywhere trusted code touches untrusted relative path names.
|
|
18
|
+
|
|
19
|
+
## Hello world
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { root } from "@openclaw/fs-safe";
|
|
23
|
+
|
|
24
|
+
const fs = await root("/safe/workspace", {
|
|
25
|
+
hardlinks: "reject",
|
|
26
|
+
symlinks: "reject",
|
|
27
|
+
mkdir: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await fs.write("notes/today.txt", "hello\n");
|
|
31
|
+
const text = await fs.readText("notes/today.txt");
|
|
32
|
+
const parsed = await fs.readJson<{ users: string[] }>("config.json");
|
|
33
|
+
await fs.copyIn("uploads/upload.png", "/tmp/upload.png");
|
|
34
|
+
await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true });
|
|
35
|
+
await fs.remove("notes/archive/today.txt");
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Pick your path
|
|
39
|
+
|
|
40
|
+
- **First time?** [Install](install.md), then walk through the [Quickstart](quickstart.md). Five minutes from `pnpm add` to a working root.
|
|
41
|
+
- **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, the [Python helper policy](python-helper.md) before you pick deployment defaults, and the [Errors](errors.md) reference so you know what to catch.
|
|
42
|
+
- **Replacing ad-hoc atomic writes.** Jump to [Atomic writes](atomic.md) or, for keyed JSON state, [JSON files](json.md).
|
|
43
|
+
- **Extracting an upload.** Start at [Archive extraction](archive.md) — handles ZIP and TAR with traversal, link, count, and byte limits.
|
|
44
|
+
- **Running an agent in a sandbox.** [Private temp workspaces](temp.md) plus [secret files](secret-file.md) cover the common scratch-and-credentials shape.
|
|
45
|
+
- **Looking up a name.** Use the [reference](errors.md) section in the sidebar — every public function has a page.
|
|
46
|
+
|
|
47
|
+
## What you get
|
|
48
|
+
|
|
49
|
+
| Surface | Use it for |
|
|
50
|
+
|---|---|
|
|
51
|
+
| [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
|
|
52
|
+
| [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper and lock-option defaults. |
|
|
53
|
+
| [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
|
|
54
|
+
| [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
|
|
55
|
+
| [`writeExternalFileWithinRoot`](output.md) | Stage external-library file output in private temp storage, then finalize under a root. |
|
|
56
|
+
| [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
|
|
57
|
+
| [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
|
|
58
|
+
| [`jsonStore`](json-store.md) | Single JSON state file with explicit fallback, atomic writes, and optional locking. |
|
|
59
|
+
| [`fileStore`](file-store.md) | Managed multi-file/blob store with modes, stream writes, copy-in, pruning, and private mode. |
|
|
60
|
+
| [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for private JSON/text state at 0600 under 0700 dirs. |
|
|
61
|
+
| [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
|
|
62
|
+
| [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
|
|
63
|
+
| [`walkDirectory`](walk.md) | Budget-bounded recursive directory scan with symlink policy and filters. |
|
|
64
|
+
| [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
|
|
65
|
+
| [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
|
|
66
|
+
| [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
|
|
67
|
+
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
|
|
68
|
+
| [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
|
|
69
|
+
| [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
|
|
70
|
+
| [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
|
|
71
|
+
| [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
|
|
72
|
+
|
|
73
|
+
## Status
|
|
74
|
+
|
|
75
|
+
Currently `0.x` — APIs are stable in shape but may be tightened before `1.0`. The [CHANGELOG](https://github.com/openclaw/fs-safe/blob/main/CHANGELOG.md) tracks visible changes. Issues and PRs at the [GitHub repo](https://github.com/openclaw/fs-safe).
|
|
76
|
+
|
|
77
|
+
Released under the [MIT license](https://github.com/openclaw/fs-safe/blob/main/LICENSE).
|