@openclaw/fs-safe 0.1.1 → 0.1.2

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +20 -1
  3. package/dist/file-store-boundary.d.ts +27 -0
  4. package/dist/file-store-boundary.d.ts.map +1 -0
  5. package/dist/file-store-boundary.js +164 -0
  6. package/dist/file-store.d.ts.map +1 -1
  7. package/dist/file-store.js +62 -72
  8. package/dist/path.d.ts +1 -0
  9. package/dist/path.d.ts.map +1 -1
  10. package/dist/path.js +6 -3
  11. package/dist/private-file-store.d.ts +5 -7
  12. package/dist/private-file-store.d.ts.map +1 -1
  13. package/dist/private-file-store.js +21 -34
  14. package/dist/root-context.d.ts +23 -0
  15. package/dist/root-context.d.ts.map +1 -0
  16. package/dist/root-context.js +62 -0
  17. package/dist/root-errors.d.ts +4 -0
  18. package/dist/root-errors.d.ts.map +1 -0
  19. package/dist/root-errors.js +21 -0
  20. package/dist/root-impl.d.ts +108 -0
  21. package/dist/root-impl.d.ts.map +1 -0
  22. package/dist/{safe-root.js → root-impl.js} +464 -251
  23. package/dist/root.d.ts +1 -107
  24. package/dist/root.d.ts.map +1 -1
  25. package/dist/root.js +1 -1332
  26. package/docs/advanced.md +100 -0
  27. package/docs/archive.md +199 -0
  28. package/docs/atomic.md +163 -0
  29. package/docs/config.md +65 -0
  30. package/docs/contributing.md +80 -0
  31. package/docs/errors.md +154 -0
  32. package/docs/file-store.md +213 -0
  33. package/docs/filename.md +0 -0
  34. package/docs/index.md +76 -0
  35. package/docs/install-path.md +167 -0
  36. package/docs/install.md +150 -0
  37. package/docs/json-store.md +200 -0
  38. package/docs/json.md +158 -0
  39. package/docs/local-roots.md +164 -0
  40. package/docs/path-scope.md +130 -0
  41. package/docs/path.md +191 -0
  42. package/docs/permissions.md +95 -0
  43. package/docs/private-file-store.md +45 -0
  44. package/docs/python-helper.md +103 -0
  45. package/docs/quickstart.md +147 -0
  46. package/docs/reading.md +174 -0
  47. package/docs/regular-file.md +175 -0
  48. package/docs/root.md +205 -0
  49. package/docs/secret-file.md +154 -0
  50. package/docs/secure-file.md +82 -0
  51. package/docs/security-model.md +99 -0
  52. package/docs/sidecar-lock.md +229 -0
  53. package/docs/store.md +44 -0
  54. package/docs/temp.md +276 -0
  55. package/docs/test-hooks.md +74 -0
  56. package/docs/testing.md +168 -0
  57. package/docs/timing.md +121 -0
  58. package/docs/types.md +155 -0
  59. package/docs/walk.md +63 -0
  60. package/docs/writing.md +222 -0
  61. package/package.json +5 -2
  62. package/dist/archive-utils.d.ts +0 -3
  63. package/dist/archive-utils.d.ts.map +0 -1
  64. package/dist/archive-utils.js +0 -26
  65. package/dist/boundary-file-read.d.ts +0 -44
  66. package/dist/boundary-file-read.d.ts.map +0 -1
  67. package/dist/boundary-file-read.js +0 -129
  68. package/dist/boundary-path.d.ts +0 -39
  69. package/dist/boundary-path.d.ts.map +0 -1
  70. package/dist/boundary-path.js +0 -598
  71. package/dist/fs-pinned-path-helper.d.ts +0 -7
  72. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  73. package/dist/fs-pinned-path-helper.js +0 -182
  74. package/dist/fs-pinned-write-helper.d.ts +0 -21
  75. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  76. package/dist/fs-pinned-write-helper.js +0 -263
  77. package/dist/hardlink-guards.d.ts +0 -7
  78. package/dist/hardlink-guards.d.ts.map +0 -1
  79. package/dist/hardlink-guards.js +0 -30
  80. package/dist/install-safe-path.d.ts +0 -20
  81. package/dist/install-safe-path.d.ts.map +0 -1
  82. package/dist/install-safe-path.js +0 -94
  83. package/dist/json-file.d.ts +0 -3
  84. package/dist/json-file.d.ts.map +0 -1
  85. package/dist/json-file.js +0 -123
  86. package/dist/json-files.d.ts +0 -20
  87. package/dist/json-files.d.ts.map +0 -1
  88. package/dist/json-files.js +0 -153
  89. package/dist/path-alias-guards.d.ts +0 -19
  90. package/dist/path-alias-guards.d.ts.map +0 -1
  91. package/dist/path-alias-guards.js +0 -21
  92. package/dist/path-guards.d.ts +0 -7
  93. package/dist/path-guards.d.ts.map +0 -1
  94. package/dist/path-guards.js +0 -49
  95. package/dist/path-safety.d.ts +0 -12
  96. package/dist/path-safety.d.ts.map +0 -1
  97. package/dist/path-safety.js +0 -50
  98. package/dist/safe-open-sync.d.ts +0 -24
  99. package/dist/safe-open-sync.d.ts.map +0 -1
  100. package/dist/safe-open-sync.js +0 -71
  101. package/dist/safe-root.d.ts +0 -123
  102. package/dist/safe-root.d.ts.map +0 -1
  103. package/dist/secure-temp-workspace.d.ts +0 -25
  104. package/dist/secure-temp-workspace.d.ts.map +0 -1
  105. package/dist/secure-temp-workspace.js +0 -136
  106. package/dist/sibling-temp-file.d.ts +0 -16
  107. package/dist/sibling-temp-file.d.ts.map +0 -1
  108. package/dist/sibling-temp-file.js +0 -73
  109. package/dist/sibling-temp-write.d.ts +0 -8
  110. package/dist/sibling-temp-write.d.ts.map +0 -1
  111. package/dist/sibling-temp-write.js +0 -40
@@ -0,0 +1,130 @@
1
+ # pathScope()
2
+
3
+ `pathScope()` is an advanced helper with the same boundary semantics as `root()`, but it operates on **absolute paths** the caller already trusts and returns plain `{ ok, path }` results instead of throwing. Use it when you want the boundary check up front before handing an absolute path to another library.
4
+
5
+ ```ts
6
+ import { pathScope } from "@openclaw/fs-safe/advanced";
7
+
8
+ const uploads = pathScope("/srv/uploads", { label: "uploads directory" });
9
+
10
+ const photo = uploads.resolve("user/photo.jpg");
11
+ if (!photo.ok) throw new Error(photo.error);
12
+
13
+ await sharp(photo.path).resize(800).toFile(/* … */);
14
+ ```
15
+
16
+ ## When to reach for it
17
+
18
+ `root()` is the right answer when you want method-style I/O (`fs.write`, `fs.readText`). `pathScope()` is the right answer when:
19
+
20
+ - You're handing absolute paths to **other libraries** that take absolute path strings (Sharp, FFmpeg, `tar`, native modules) and just want the boundary check up front.
21
+ - You want to validate user-supplied paths in bulk and return a typed `Result` rather than catching exceptions.
22
+ - You want the scope's `label` to appear in the error message: `"Invalid path: must stay within uploads directory"`.
23
+
24
+ ## Signature
25
+
26
+ ```ts
27
+ function pathScope(rootDir: string, options: PathScopeOptions): PathScope;
28
+
29
+ type PathScopeOptions = {
30
+ label: string; // appears in error messages
31
+ };
32
+
33
+ type PathScopeResolveOptions = {
34
+ defaultName?: string; // used when the requested path is empty
35
+ };
36
+
37
+ type PathResult = { ok: true; path: string } | { ok: false; error: string };
38
+ type PathsResult = { ok: true; paths: string[] } | { ok: false; error: string };
39
+
40
+ type PathScope = {
41
+ rootDir: string;
42
+ label: string;
43
+ resolve(requestedPath: string, options?: PathScopeResolveOptions): PathResult;
44
+ resolveAll(requestedPaths: string[]): PathsResult;
45
+ existing(requestedPaths: string[]): Promise<PathsResult>;
46
+ files(requestedPaths: string[]): Promise<PathsResult>;
47
+ writable(requestedPath: string, options?: PathScopeResolveOptions): Promise<PathResult>;
48
+ ensureDir(requestedPath: string, options?: PathScopeResolveOptions & { mode?: number }): Promise<PathResult>;
49
+ };
50
+ ```
51
+
52
+ ## Methods
53
+
54
+ ### `resolve(rel, options?)`
55
+
56
+ Synchronous, lexical only — no filesystem touch. Resolves the relative path against `rootDir` and verifies the result stays inside the scope. Use when you only need the path string:
57
+
58
+ ```ts
59
+ const r = uploads.resolve("user/photo.jpg");
60
+ if (!r.ok) return reply(400, r.error);
61
+ console.log(r.path); // /srv/uploads/user/photo.jpg
62
+ ```
63
+
64
+ If the requested path is empty, `defaultName` (when set) is used.
65
+
66
+ ### `resolveAll(rels)`
67
+
68
+ Synchronous bulk resolve. Returns `{ ok: true, paths }` if every input is in scope, or `{ ok: false, error }` on the first failure.
69
+
70
+ ### `existing(rels)`
71
+
72
+ Async. Like `resolveAll`, but additionally allows trusted absolute paths whose `realpath` is inside the scope. Useful when callers pass either a relative path or an absolute path you've already validated.
73
+
74
+ ### `files(rels)`
75
+
76
+ Async. Strict variant: every input must already exist as a regular file inside the scope, with `nlink === 1`. Symlinked entries are rejected.
77
+
78
+ ### `writable(rel, options?)`
79
+
80
+ Async. Resolves a writable target: ensures the parent directory exists inside the scope, refuses targets whose existing inode is a symlink or hardlinked, and returns the absolute path to use for the write.
81
+
82
+ ```ts
83
+ const t = await uploads.writable("reports/2026/05/report.pdf");
84
+ if (!t.ok) return reply(400, t.error);
85
+ await fs.writeFile(t.path, body);
86
+ ```
87
+
88
+ ### `ensureDir(rel, options?)`
89
+
90
+ Async. `mkdir -p` inside the scope. Walks each segment, refuses any symlink in the path, creates missing directories. Optional `mode` sets the directory mode.
91
+
92
+ ```ts
93
+ const dir = await uploads.ensureDir("inbox", { mode: 0o755 });
94
+ if (!dir.ok) return reply(500, dir.error);
95
+ ```
96
+
97
+ ## Result type vs throwing
98
+
99
+ `pathScope` returns `{ ok: true, path }` / `{ ok: false, error }` instead of throwing. This makes it pleasant to use at validation boundaries:
100
+
101
+ ```ts
102
+ function handle(req: Req, res: Res) {
103
+ const r = uploads.resolve(req.body.path);
104
+ if (!r.ok) return res.status(400).json({ error: r.error });
105
+ return doWork(r.path);
106
+ }
107
+ ```
108
+
109
+ For exception-flavored APIs, use `root()` instead — it throws `FsSafeError` with a typed `code`.
110
+
111
+ ## Combine with `root()`
112
+
113
+ A common pattern at the edge of an HTTP handler: validate the input with `pathScope`, do the I/O with `root()`:
114
+
115
+ ```ts
116
+ const uploads = pathScope("/srv/uploads", { label: "uploads" });
117
+ const fs = await root("/srv/uploads", { hardlinks: "reject" });
118
+
119
+ const r = uploads.resolve(req.body.path);
120
+ if (!r.ok) return reply(400, r.error);
121
+ const rel = path.relative(fs.rootReal, r.path);
122
+
123
+ const text = await fs.readText(rel);
124
+ ```
125
+
126
+ ## See also
127
+
128
+ - [`root()`](root.md) — method-style boundary that throws on failure.
129
+ - [Path helpers](path.md) — `isPathInside`, `safeRealpathSync`, `isWithinDir` for ad-hoc checks.
130
+ - [Errors](errors.md) — error semantics (note: `pathScope` returns errors, doesn't throw).
package/docs/path.md ADDED
@@ -0,0 +1,191 @@
1
+ # Path helpers
2
+
3
+ `@openclaw/fs-safe/path` is the lower-level lexical and canonical path surface — useful when you have your own logic that needs to know "is this path inside that directory" without going through `root()` or `pathScope()`.
4
+
5
+ ```ts
6
+ import {
7
+ isPathInside,
8
+ isPathInsideWithRealpath,
9
+ isWithinDir,
10
+ resolveSafeBaseDir,
11
+ safeRealpathSync,
12
+ safeStatSync,
13
+ assertNoNulPathInput,
14
+ isNotFoundPathError,
15
+ isSymlinkOpenError,
16
+ hasNodeErrorCode,
17
+ } from "@openclaw/fs-safe/path";
18
+ ```
19
+
20
+ Only `root()`, `FsSafeError`, and the Python helper config live on the main entry. Path helpers are deliberately a subpath import so the main entry stays small.
21
+
22
+ ## Boundary checks
23
+
24
+ ### `isPathInside(rootDir, target)`
25
+
26
+ Pure lexical check. Returns `true` if `target` is `rootDir` itself or a descendant, after normalizing both inputs. **Does not** touch the filesystem — does not follow symlinks.
27
+
28
+ ```ts
29
+ isPathInside("/srv/uploads", "/srv/uploads/photo.jpg"); // true
30
+ isPathInside("/srv/uploads", "/srv/uploads/../escape.txt"); // false
31
+ isPathInside("/srv/uploads", "/srv/uploads-other/x"); // false
32
+ isPathInside("/srv/uploads", "/srv/uploads"); // true (root itself counts)
33
+ ```
34
+
35
+ The check is platform-aware: on Windows, paths are normalized for case and separator before comparison.
36
+
37
+ ### `isPathInsideWithRealpath(rootDir, target, opts?)`
38
+
39
+ Synchronous. Same as `isPathInside`, but resolves both inputs through `realpath` first. Use this when you want the canonical answer and either input might be a symlink.
40
+
41
+ ```ts
42
+ isPathInsideWithRealpath("/srv/uploads", "/srv/symlink-to-elsewhere"); // false
43
+ ```
44
+
45
+ ```ts
46
+ type Options = {
47
+ requireRealpath?: boolean; // default true
48
+ cache?: Map<string, string>;
49
+ };
50
+ ```
51
+
52
+ Does not throw on missing inputs — `realpath` failures are absorbed by the underlying `safeRealpathSync`. By default (`requireRealpath: true`) the function returns `false` when either input cannot be resolved. Pass `{ requireRealpath: false }` to fall back to the lexical answer from `isPathInside` instead.
53
+
54
+ ### `isWithinDir(rootDir, targetPath)`
55
+
56
+ Convenience wrapper around `isPathInside`. Same semantics, different name kept for callers that prefer the noun phrase.
57
+
58
+ ### `resolveSafeBaseDir(rootDir)`
59
+
60
+ Resolve a base directory to an absolute, normalized form ready for prefix comparison. Pre-normalized directories make subsequent `isPathInside` checks unambiguous.
61
+
62
+ ```ts
63
+ const base = resolveSafeBaseDir("/srv/uploads/."); // "/srv/uploads"
64
+ ```
65
+
66
+ ## Realpath and stat
67
+
68
+ ### `safeRealpathSync(targetPath, cache?)`
69
+
70
+ Synchronous `realpath` that returns `null` instead of throwing on any error. Pass an optional `Map<string, string>` to cache results across calls within a single operation.
71
+
72
+ ```ts
73
+ const real = safeRealpathSync("/srv/uploads/photo.jpg");
74
+ if (real === null) return notFound();
75
+ ```
76
+
77
+ All `realpath` failures collapse to `null` — there is no distinction between `ENOENT`, `EACCES`, and other I/O errors. Use `fs.realpathSync` directly if you need to branch on the error code.
78
+
79
+ ### `safeStatSync(targetPath)`
80
+
81
+ Synchronous `stat` that returns `null` instead of throwing on missing paths. Returns `Stats` on success.
82
+
83
+ ```ts
84
+ const stat = safeStatSync("/srv/uploads/photo.jpg");
85
+ if (!stat?.isFile()) return notFound();
86
+ ```
87
+
88
+ ### `assertNoNulPathInput(filePath, message?)`
89
+
90
+ Throws `FsSafeError` with code `invalid-path` when a path string contains an embedded NUL byte. Use it before calling Node `fs` APIs directly; Node's native error can include raw path text in the message.
91
+
92
+ ## Error inspection
93
+
94
+ ### `isNotFoundPathError(err)`
95
+
96
+ `true` if the error is a `NodeJS.ErrnoException` with code `ENOENT` (file or directory missing).
97
+
98
+ ```ts
99
+ try {
100
+ await fs.readFile(p);
101
+ } catch (err) {
102
+ if (isNotFoundPathError(err)) return null;
103
+ throw err;
104
+ }
105
+ ```
106
+
107
+ ### `isSymlinkOpenError(err)`
108
+
109
+ `true` if the error indicates a symlink-related open failure (typically `ELOOP` or platform-specific symlink rejections from `O_NOFOLLOW`).
110
+
111
+ ### `hasNodeErrorCode(err, code)`
112
+
113
+ Generic helper. `true` if `err` is a `NodeJS.ErrnoException` with the matching `code` string.
114
+
115
+ ```ts
116
+ if (hasNodeErrorCode(err, "EACCES")) return reply(403);
117
+ ```
118
+
119
+ ## When to use these vs `root()`
120
+
121
+ | Path helpers | `root()` |
122
+ |---|---|
123
+ | Pure functions over absolute path strings. | Boundary handle with method-style I/O. |
124
+ | No I/O performed (except `safeRealpathSync`/`safeStatSync`). | Every method goes to disk. |
125
+ | Fits inside other helpers / validation pipelines. | Standalone consumer of caller-supplied paths. |
126
+
127
+ If you're writing a validator that says "is this safe?" without performing the operation, the path helpers are the right tool. If you're going to perform the read or write, use `root()` so the boundary check is fused with the operation.
128
+
129
+ ## Common patterns
130
+
131
+ ### "Resolve, then assert it's inside my dir"
132
+
133
+ ```ts
134
+ import { isPathInsideWithRealpath, isNotFoundPathError } from "@openclaw/fs-safe/path";
135
+
136
+ let canonical: string;
137
+ try {
138
+ canonical = await fs.realpath(input);
139
+ } catch (err) {
140
+ if (isNotFoundPathError(err)) return reply(404);
141
+ throw err;
142
+ }
143
+ if (!isPathInsideWithRealpath("/srv/uploads", canonical)) {
144
+ return reply(403);
145
+ }
146
+ ```
147
+
148
+ ### Cache realpaths in a request scope
149
+
150
+ ```ts
151
+ const cache = new Map<string, string>();
152
+ for (const p of inputs) {
153
+ const real = safeRealpathSync(p, cache);
154
+ if (real && isPathInside(root, real)) keep.push(real);
155
+ }
156
+ ```
157
+
158
+ ### Branch on `ENOENT` vs other errors
159
+
160
+ ```ts
161
+ try {
162
+ return await fs.readFile(p);
163
+ } catch (err) {
164
+ if (isNotFoundPathError(err)) return null;
165
+ if (hasNodeErrorCode(err, "EACCES")) return null;
166
+ throw err;
167
+ }
168
+ ```
169
+
170
+ ## Path-policy helpers
171
+
172
+ `path-policy.ts` exposes two assertions used by `root()` internally; they are exported for callers building their own helpers:
173
+
174
+ ```ts
175
+ import {
176
+ assertNoPathAliasEscape,
177
+ assertNoHardlinkedFinalPath,
178
+ PATH_ALIAS_POLICIES,
179
+ } from "@openclaw/fs-safe/advanced";
180
+ ```
181
+
182
+ - `assertNoPathAliasEscape({ rootRealPath, candidatePath, policy })` — async. Asserts the candidate's resolved real path is inside the root. Configurable via `PATH_ALIAS_POLICIES` (which currently ships only the default `"strict"` policy).
183
+ - `assertNoHardlinkedFinalPath({ filePath })` — async. Throws if the file at `filePath` has `nlink > 1`.
184
+
185
+ Use these when writing a custom helper that wants the same guards `root()` uses but with different surrounding logic.
186
+
187
+ ## See also
188
+
189
+ - [`root()`](root.md) — the high-level boundary that uses these checks internally.
190
+ - [`pathScope()`](path-scope.md) — `Result`-style scope checks.
191
+ - [Reading](reading.md) — `Root.read()` and friends use `isPathInside` + identity checks.
@@ -0,0 +1,95 @@
1
+ # Permissions
2
+
3
+ `@openclaw/fs-safe/permissions` contains the curated mode and permission inspection helpers used by secure file reads and by applications that want to report actionable permission problems.
4
+
5
+ ```ts
6
+ import {
7
+ formatPermissionDetail,
8
+ formatPermissionRemediation,
9
+ inspectPathPermissions,
10
+ } from "@openclaw/fs-safe/permissions";
11
+
12
+ const perms = await inspectPathPermissions("/var/lib/app/auth.token");
13
+ console.log(formatPermissionDetail("/var/lib/app/auth.token", perms));
14
+ if (perms.ok && (perms.groupReadable || perms.worldReadable)) {
15
+ console.log(
16
+ formatPermissionRemediation({
17
+ targetPath: "/var/lib/app/auth.token",
18
+ perms,
19
+ isDir: false,
20
+ posixMode: 0o600,
21
+ }),
22
+ );
23
+ }
24
+ ```
25
+
26
+ ## POSIX helpers
27
+
28
+ ```ts
29
+ safeStat(path);
30
+ inspectPathPermissions(path, options?);
31
+ formatPermissionDetail(path, check);
32
+ formatPermissionRemediation({ targetPath, perms, isDir, posixMode });
33
+ modeBits(mode);
34
+ formatOctal(bits);
35
+ isWorldWritable(bits);
36
+ isGroupWritable(bits);
37
+ isWorldReadable(bits);
38
+ isGroupReadable(bits);
39
+ ```
40
+
41
+ `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below.
42
+
43
+ ## Advanced Windows ACL helpers
44
+
45
+ The low-level Windows ACL parser and `icacls` command builders live in `@openclaw/fs-safe/advanced`:
46
+
47
+ ```ts
48
+ import {
49
+ createIcaclsResetCommand,
50
+ formatIcaclsResetCommand,
51
+ formatWindowsAclSummary,
52
+ inspectWindowsAcl,
53
+ parseIcaclsOutput,
54
+ resolveWindowsUserPrincipal,
55
+ summarizeWindowsAcl,
56
+ } from "@openclaw/fs-safe/advanced";
57
+
58
+ inspectWindowsAcl(path, { env, exec });
59
+ parseIcaclsOutput(output, targetPath);
60
+ summarizeWindowsAcl(entries, env);
61
+ formatWindowsAclSummary(summary);
62
+ formatIcaclsResetCommand(targetPath, { isDir, env });
63
+ createIcaclsResetCommand(targetPath, { isDir, env });
64
+ resolveWindowsUserPrincipal(env);
65
+ ```
66
+
67
+ The default Windows inspector calls `icacls.exe /sid` and classifies principals as trusted, world, or group. Trusted defaults include the current user, SYSTEM, and Administrators. The parser is on the advanced surface so tests and CLIs can process captured `icacls` output without spawning a process.
68
+
69
+ Use `createIcaclsResetCommand()` when you need a structured command and argv pair. Use `formatIcaclsResetCommand()` when you only need a remediation string for a user-facing message.
70
+
71
+ ## Types
72
+
73
+ ```ts
74
+ type PermissionCheck = {
75
+ ok: boolean;
76
+ isSymlink: boolean;
77
+ isDir: boolean;
78
+ mode: number | null;
79
+ bits: number | null;
80
+ source: "posix" | "windows-acl" | "unknown";
81
+ worldWritable: boolean;
82
+ groupWritable: boolean;
83
+ worldReadable: boolean;
84
+ groupReadable: boolean;
85
+ aclSummary?: string;
86
+ error?: string;
87
+ };
88
+ ```
89
+
90
+ `ok: false` means the path itself could not be inspected. `ok: true` with `source: "unknown"` means basic stat information was available, but the platform-specific permission source could not be verified.
91
+
92
+ ## See also
93
+
94
+ - [Secure file reads](secure-file.md) — fd-pinned reads that enforce these checks.
95
+ - [Errors](errors.md) — permission-related `FsSafeError` codes.
@@ -0,0 +1,45 @@
1
+ # Private file-store mode
2
+
3
+ Private state is not a separate store family. Use `fileStore({ private: true })`
4
+ when a directory holds credentials, tokens, auth profiles, or other private
5
+ JSON/text state.
6
+
7
+ ```ts
8
+ import { fileStore } from "@openclaw/fs-safe/store";
9
+
10
+ const store = fileStore({ rootDir: "/var/lib/app", private: true });
11
+
12
+ await store.writeJson("state.json", state);
13
+ const loaded = await store.readJsonIfExists<State>("state.json");
14
+ ```
15
+
16
+ ## Behavior
17
+
18
+ - Writes create parent directories at `0o700` and files at `0o600` unless you
19
+ pass stricter `dirMode` / `mode` options.
20
+ - Private-mode writes route through the secret-file atomic path, which refuses
21
+ symlink parent components and re-asserts mode after rename.
22
+ - `readText()` and `readJson()` are strict and throw on missing files.
23
+ - `readTextIfExists()` and `readJsonIfExists()` return `null` on missing files.
24
+ - `write()`, `writeText()`, `writeJson()`, `writeStream()`, and `copyIn()` all
25
+ keep the same root-relative `FileStore` shape.
26
+
27
+ ## Sync writes
28
+
29
+ Use `fileStoreSync({ private: true })` for boot paths or sync-only integration
30
+ points:
31
+
32
+ ```ts
33
+ import { fileStoreSync } from "@openclaw/fs-safe/store";
34
+
35
+ fileStoreSync({ rootDir: "/var/lib/app", private: true }).writeJson("config.json", config);
36
+ ```
37
+
38
+ The sync store intentionally exposes a smaller surface: path resolution,
39
+ lenient reads, and atomic text/JSON writes.
40
+
41
+ ## See also
42
+
43
+ - [`fileStore`](file-store.md) — full store API.
44
+ - [Secret files](secret-file.md) — standalone credential file reads and writes.
45
+ - [JSON files](json.md) — strict/lenient JSON helpers without a bound store.
@@ -0,0 +1,103 @@
1
+ ---
2
+ title: Python helper policy
3
+ description: "How fs-safe uses its optional persistent Python helper, how to configure it, and what Node-only mode changes."
4
+ ---
5
+
6
+ # Python helper policy
7
+
8
+ `fs-safe` is a Node library. On POSIX systems, it can optionally keep one persistent Python helper process for filesystem operations that Node does not expose ergonomically as fd-relative APIs.
9
+
10
+ The helper is not a sandbox and does not add new authority. It uses the same process user and the same filesystem permissions as your Node process. Its job is narrower: reduce race windows around parent-directory mutations after a root boundary has already been chosen.
11
+
12
+ ## Default
13
+
14
+ The package default is:
15
+
16
+ ```ts
17
+ configureFsSafePython({ mode: "auto" });
18
+ ```
19
+
20
+ `auto` means:
21
+
22
+ - use the helper for supported POSIX operations when it starts successfully;
23
+ - fall back to Node-only behavior when Python is missing, disabled by the host, or unavailable;
24
+ - keep the public API working in ordinary desktop, CI, Docker, and bundled-app environments.
25
+
26
+ Applications can choose a stricter or simpler policy before the first filesystem operation:
27
+
28
+ ```ts
29
+ import { configureFsSafePython } from "@openclaw/fs-safe/config";
30
+
31
+ configureFsSafePython({ mode: "off" }); // never spawn Python
32
+ configureFsSafePython({ mode: "require" }); // fail closed if helper cannot start
33
+ ```
34
+
35
+ Environment variables provide the same policy:
36
+
37
+ ```bash
38
+ FS_SAFE_PYTHON_MODE=auto # auto | off | require
39
+ FS_SAFE_PYTHON=/usr/bin/python3
40
+ ```
41
+
42
+ OpenClaw compatibility aliases are accepted too: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`, `OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`.
43
+
44
+ ## What the helper does
45
+
46
+ Node's `fs` API is path-string oriented. It exposes `O_NOFOLLOW`, file handles, and some identity checks, but not a complete ergonomic `openat` / `renameat` / `unlinkat` / `mkdirat` surface for every operation `root()` needs.
47
+
48
+ The helper fills that gap for supported POSIX operations:
49
+
50
+ - stat/list paths relative to an already-open root directory;
51
+ - create directories while walking from a pinned parent;
52
+ - remove entries relative to a pinned parent;
53
+ - move entries with fd-relative rename semantics;
54
+ - run parent-fd write paths used by atomic replacement helpers.
55
+
56
+ `fs-safe` sends requests to the helper over a JSON-lines protocol. It is one persistent process per Node process, not one Python spawn per filesystem call.
57
+
58
+ ## What you lose with `mode: "off"`
59
+
60
+ Node-only mode still keeps the important application-level guardrails:
61
+
62
+ - root-relative path validation;
63
+ - canonical root checks;
64
+ - no-follow opens where Node/platform support exists;
65
+ - file identity checks around reads and writes;
66
+ - atomic sibling-temp replacement;
67
+ - hardlink/symlink policy checks where the API requests them;
68
+ - byte limits and structured `FsSafeError` failures.
69
+
70
+ What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation. Without fd-relative mutation, `root().move()`, `root().remove()`, `root().mkdir()`, and some write paths rely on Node path operations plus pre/post checks instead of parent-fd syscalls.
71
+
72
+ That is usually acceptable when the root directory is only writable by the trusted application user. It is not the right posture if untrusted local processes can race writes in the same tree and you are relying on `fs-safe` as part of the security boundary.
73
+
74
+ ## Choosing a mode
75
+
76
+ | Mode | Use when |
77
+ |---|---|
78
+ | `auto` | You want the strongest available POSIX path when Python exists, but installs should still work without it. This is the package default. |
79
+ | `off` | You want deterministic Node-only behavior, no Python process, or a runtime that forbids spawning Python. |
80
+ | `require` | The fd-relative helper is part of your security posture and startup/runtime should fail closed if it is unavailable. |
81
+
82
+ If you deploy with `require`, set `FS_SAFE_PYTHON` to an absolute interpreter path and test it in the same container, bundle, service manager, or sandbox that runs your app.
83
+
84
+ ## Application defaults
85
+
86
+ Libraries should normally leave the package default alone. Applications can set a process-global policy once at startup:
87
+
88
+ ```ts
89
+ import { configureFsSafePython } from "@openclaw/fs-safe/config";
90
+
91
+ if (!process.env.FS_SAFE_PYTHON_MODE) {
92
+ configureFsSafePython({ mode: "off" });
93
+ }
94
+ ```
95
+
96
+ This is the right shape for apps that want to make Python an explicit operator choice while still letting deployment env vars opt back into `auto` or `require`.
97
+
98
+ ## Related pages
99
+
100
+ - [Security model](security-model.md) — what `root()` does and does not promise.
101
+ - [Root API](root.md) — root-bounded read/write/move/remove methods.
102
+ - [Errors](errors.md) — `helper-unavailable` and `helper-failed` handling.
103
+ - [Testing](testing.md) — forcing helper modes in tests.