@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/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.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
Five minutes. By the end you will have a working `root()` and know how to read, write, atomically replace, and unpack an archive — without your code being able to escape the workspace.
|
|
4
|
+
|
|
5
|
+
If you have used Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), this is the same shape: a capability-style handle that carries the boundary across every operation. The first thing to internalize is that you stop reasoning about *paths* and start reasoning about *the handle*.
|
|
6
|
+
|
|
7
|
+
## 1. Build a root
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { root } from "@openclaw/fs-safe";
|
|
11
|
+
|
|
12
|
+
const fs = await root("/srv/jobs/incoming", {
|
|
13
|
+
hardlinks: "reject", // refuse files that are hardlinks of out-of-tree inodes
|
|
14
|
+
symlinks: "reject", // refuse to traverse a symlink during open
|
|
15
|
+
mkdir: true, // create missing parent dirs on write
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`root()` resolves the directory through the real filesystem (so symlinked roots become canonical) and verifies it exists. The defaults you pass apply to every call below; per-call options override them.
|
|
20
|
+
|
|
21
|
+
If the root directory itself does not exist yet, `root()` throws `FsSafeError` with code `not-found`. Either create the directory before calling `root()`, or call `await fs.ensureRoot()` after a successful `root()` to create empty subpaths.
|
|
22
|
+
|
|
23
|
+
## 2. Read and write text
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
await fs.write("notes/today.txt", "hello\n");
|
|
27
|
+
const text = await fs.readText("notes/today.txt");
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Writes use a sibling temp file plus `rename`, so a partial write never appears at the destination. Reads open with `O_NOFOLLOW` where available and verify the opened fd matches the path identity before returning the buffer.
|
|
31
|
+
|
|
32
|
+
`create()` is the don't-clobber variant of `write()` and throws `already-exists` when the target is already there:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
await fs.create("notes/README.md", "seed\n"); // throws if it already exists
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 3. JSON, with parsing
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
type Config = { tokens: string[]; updatedAt: string };
|
|
42
|
+
|
|
43
|
+
await fs.writeJson("state/config.json", { tokens: [], updatedAt: new Date().toISOString() }, {
|
|
44
|
+
space: 2,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const config = await fs.readJson<Config>("state/config.json");
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`writeJson` stringifies and writes atomically. `readJson` reads through the same boundary and parses; validate the shape at your application boundary if it came from a less-trusted source.
|
|
51
|
+
|
|
52
|
+
## 4. Move and remove
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true });
|
|
56
|
+
await fs.remove("notes/archive/today.txt");
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`move()` defaults to no clobber. Pass `{ overwrite: true }` when replacing the target is intentional. `remove()` works on files and empty directories. For non-empty directories, list and remove children first or use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
|
|
60
|
+
|
|
61
|
+
## 5. Inspect
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const here = await fs.exists("state/config.json"); // boolean
|
|
65
|
+
const stat = await fs.stat("state/config.json"); // { kind, size, mtimeMs, ... }
|
|
66
|
+
const names = await fs.list("state"); // string[]
|
|
67
|
+
const entries = await fs.list("state", { withFileTypes: true }); // DirEntry[]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`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.
|
|
71
|
+
|
|
72
|
+
## 6. Catch escapes
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { FsSafeError } from "@openclaw/fs-safe";
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await fs.write("../escape.txt", "x");
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof FsSafeError && err.code === "outside-workspace") {
|
|
81
|
+
// log, count, drop the request
|
|
82
|
+
} else {
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Error codes are a closed union — branch on `err.code` instead of matching message text. The full list lives in the [Errors](errors.md) reference.
|
|
89
|
+
|
|
90
|
+
## 7. Replace a config file atomically
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
|
|
94
|
+
|
|
95
|
+
await replaceFileAtomic({
|
|
96
|
+
filePath: "/srv/jobs/incoming/state/config.json",
|
|
97
|
+
content: JSON.stringify(state, null, 2),
|
|
98
|
+
mode: 0o600,
|
|
99
|
+
syncTempFile: true,
|
|
100
|
+
syncParentDir: true,
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Use `replaceFileAtomic` directly when you have an absolute path you trust and want sibling-temp + rename without going through `root()`. See [Atomic writes](atomic.md).
|
|
105
|
+
|
|
106
|
+
## 8. Unpack a ZIP
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
|
|
110
|
+
|
|
111
|
+
const kind = resolveArchiveKind("upload.zip");
|
|
112
|
+
if (!kind) throw new Error("unsupported archive");
|
|
113
|
+
|
|
114
|
+
await extractArchive({
|
|
115
|
+
archivePath: "/srv/jobs/incoming/uploads/upload.zip",
|
|
116
|
+
destDir: "/srv/jobs/incoming/extracted",
|
|
117
|
+
kind,
|
|
118
|
+
timeoutMs: 15_000,
|
|
119
|
+
limits: {
|
|
120
|
+
maxArchiveBytes: 256 * 1024 * 1024,
|
|
121
|
+
maxEntries: 50_000,
|
|
122
|
+
maxExtractedBytes: 512 * 1024 * 1024,
|
|
123
|
+
maxEntryBytes: 256 * 1024 * 1024,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Extraction stages into a private dir and merges through the same boundary used by direct writes, so a symlinked entry can't trick the merge into following an out-of-tree path. See [Archive extraction](archive.md).
|
|
129
|
+
|
|
130
|
+
## 9. Get a private scratch directory
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
|
134
|
+
|
|
135
|
+
await withTempWorkspace({ rootDir: "/srv/jobs/tmp", prefix: "build-" }, async (workspace) => {
|
|
136
|
+
await fs.copyIn("input.bin", "/tmp/source.bin");
|
|
137
|
+
// ...do work in workspace.dir; auto-cleaned on exit
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The directory is mode `0700`, sits under a per-user secure temp root, and is removed when the callback returns or throws. See [Temp workspaces](temp.md).
|
|
142
|
+
|
|
143
|
+
## Where to next
|
|
144
|
+
|
|
145
|
+
- [Security model](security-model.md) — exactly what the boundary defends against, what it does not.
|
|
146
|
+
- [Root API](root.md) — every method on the `Root` handle, including streaming and reader callbacks.
|
|
147
|
+
- [Errors](errors.md) — the full code union and what each one means.
|