[codex] Extract filesystem safety primitives (#77918)

* refactor: extract filesystem safety primitives

* refactor: use fs-safe for file access helpers

* refactor: reuse fs-safe for media reads

* refactor: use fs-safe for image reads

* refactor: reuse fs-safe in qqbot media opener

* refactor: reuse fs-safe for local media checks

* refactor: consume cleaner fs-safe api

* refactor: align fs-safe json option names

* fix: preserve fs-safe migration contracts

* refactor: use fs-safe primitive subpaths

* refactor: use grouped fs-safe subpaths

* refactor: align fs-safe api usage

* refactor: adapt private state store api

* chore: refresh proof gate

* refactor: follow fs-safe json api split

* refactor: follow reduced fs-safe surface

* build: default fs-safe python helper off

* fix: preserve fs-safe plugin sdk aliases

* refactor: consolidate fs-safe usage

* refactor: unify fs-safe store usage

* refactor: trim fs-safe temp workspace usage

* refactor: hide low-level fs-safe primitives

* build: use published fs-safe package

* fix: preserve outbound recovery durability after rebase

* chore: refresh pr checks
This commit is contained in:
Peter Steinberger
2026-05-06 02:15:17 +01:00
committed by GitHub
parent 61481eb34f
commit 538605ff44
356 changed files with 4918 additions and 11913 deletions

View File

@@ -1,2 +1,2 @@
fe061b6f35adb2b152d8f48244a94d4934b335143cc5f5aebb8cc96e5ba8b287 plugin-sdk-api-baseline.json
495248d5981456192aaf7da2ed23d5951eaa6d9e59d70c716ab91c3da3620e73 plugin-sdk-api-baseline.jsonl
1a06492fe05d1c9dc3194677f52d57ec90468b93023b70d0852ef01d87c7eae3 plugin-sdk-api-baseline.json
c950a1923c0dc7d31120a3010e24217bcf22fd9cacbe102d3ae19b0120c0f648 plugin-sdk-api-baseline.jsonl

View File

@@ -59,6 +59,10 @@
"source": "Gateway RPC reference",
"target": "Gateway RPC 参考"
},
{
"source": "Secure file operations",
"target": "安全文件操作"
},
{
"source": "Sessions",
"target": "会话"
@@ -758,5 +762,9 @@
{
"source": "/cli/config",
"target": "/cli/config"
},
{
"source": "fs-safe Cleanup Plan",
"target": "fs-safe Cleanup Plan"
}
]

View File

@@ -1501,6 +1501,7 @@
"group": "Security and sandboxing",
"pages": [
"gateway/security/index",
"gateway/security/secure-file-operations",
"gateway/security/audit-checks",
"gateway/operator-scopes",
"gateway/sandboxing",

View File

@@ -65,6 +65,12 @@ OpenClaw assumes the host and config boundary are trusted:
- Session identifiers (`sessionKey`, session IDs, labels) are routing selectors, not authorization tokens.
- If several people can message one tool-enabled agent, each of them can steer that same permission set. Per-user session/memory isolation helps privacy, but does not convert a shared agent into per-user host authorization.
### Secure file operations
OpenClaw uses `@openclaw/fs-safe` for root-bounded file access, atomic writes, archive extraction, temp workspaces, and secret-file helpers. OpenClaw defaults fs-safe's optional POSIX Python helper to **off**; set `OPENCLAW_FS_SAFE_PYTHON_MODE=auto` or `require` only when you want the extra fd-relative mutation hardening and can support a Python runtime.
Details: [Secure file operations](/gateway/security/secure-file-operations).
### Shared Slack workspace: real risk
If "everyone in Slack can message the bot," the core risk is delegated tool authority:

View File

@@ -0,0 +1,76 @@
---
summary: "How OpenClaw handles local file access safely, and why the optional fs-safe Python helper is off by default"
read_when:
- Changing file access, archive extraction, workspace storage, or plugin filesystem helpers
title: "Secure file operations"
---
OpenClaw uses [`@openclaw/fs-safe`](https://github.com/openclaw/fs-safe) for security-sensitive local file operations: root-bounded reads/writes, atomic replacement, archive extraction, temp workspaces, JSON state, and secret-file handling.
The goal is a consistent **library guardrail** for trusted OpenClaw code that receives untrusted path names. It is not a sandbox. Host filesystem permissions, OS users, containers, and the agent/tool policy still define the real blast radius.
## Default: no Python helper
OpenClaw defaults the fs-safe POSIX Python helper to **off**.
Why:
- the gateway should not spawn a persistent Python sidecar unless an operator opted into it;
- many installs do not need the extra parent-directory mutation hardening;
- disabling Python keeps package/runtime behavior more predictable across desktop, Docker, CI, and bundled app environments.
OpenClaw only changes the default. If you explicitly set a mode, fs-safe honors it:
```bash
# Default OpenClaw behavior: Node-only fs-safe fallbacks.
OPENCLAW_FS_SAFE_PYTHON_MODE=off
# Opt into the helper when available, falling back if unavailable.
OPENCLAW_FS_SAFE_PYTHON_MODE=auto
# Fail closed if the helper cannot start.
OPENCLAW_FS_SAFE_PYTHON_MODE=require
# Optional explicit interpreter.
OPENCLAW_FS_SAFE_PYTHON=/usr/bin/python3
```
The generic fs-safe names also work: `FS_SAFE_PYTHON_MODE` and `FS_SAFE_PYTHON`.
## What stays protected without Python
With the helper off, OpenClaw still uses fs-safe's Node paths for:
- rejecting relative-path escapes such as `..`, absolute paths, and path separators where only names are allowed;
- resolving operations through a trusted root handle instead of ad-hoc `path.resolve(...).startsWith(...)` checks;
- refusing symlink and hardlink patterns on APIs that require that policy;
- opening files with identity checks where the API returns or consumes file contents;
- atomic sibling-temp writes for state/config files;
- byte limits for reads and archive extraction;
- private modes for secrets and state files where the API requires them.
These protections cover the normal OpenClaw threat model: trusted gateway code handling untrusted model/plugin/channel path input inside a single trusted operator boundary.
## What Python adds
On POSIX, fs-safe's optional helper keeps one persistent Python process and uses fd-relative filesystem operations for parent-directory mutations such as rename, remove, mkdir, stat/list, and some write paths.
That narrows same-UID race windows where another process can swap a parent directory between validation and mutation. It is defense in depth for hosts where untrusted local processes can modify the same directories OpenClaw is operating in.
If your deployment has that risk and Python is guaranteed to exist, use:
```bash
OPENCLAW_FS_SAFE_PYTHON_MODE=require
```
Use `require` rather than `auto` when the helper is part of your security posture; `auto` intentionally falls back to Node-only behavior if the helper is unavailable.
## Plugin and core guidance
- Plugin-facing file access should go through `openclaw/plugin-sdk/*` helpers, not raw `fs`, when a path comes from a message, model output, config, or plugin input.
- Core code should use the local fs-safe wrappers under `src/infra/*` so OpenClaw's process policy is applied consistently.
- Archive extraction should use the fs-safe archive helpers with explicit size, entry-count, link, and destination limits.
- Secrets should use OpenClaw secret helpers or fs-safe secret/private-state helpers; do not hand-roll mode checks around `fs.writeFile`.
- If you need hostile local-user isolation, do not rely on fs-safe alone. Run separate gateways under separate OS users/hosts or use sandboxing.
Related: [Security](/gateway/security), [Sandboxing](/gateway/sandboxing), [Exec approvals](/tools/exec-approvals), [Secrets](/gateway/secrets).

View File

@@ -425,7 +425,7 @@ releases.
| `plugin-sdk/approval-native-runtime` | Approval target helpers | Native approval target/account binding helpers |
| `plugin-sdk/approval-reply-runtime` | Approval reply helpers | Exec/plugin approval reply payload helpers |
| `plugin-sdk/channel-runtime-context` | Channel runtime-context helpers | Generic channel runtime-context register/get/watch helpers |
| `plugin-sdk/security-runtime` | Security helpers | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/security-runtime` | Security helpers | Shared trust, DM gating, root-bounded file/path helpers, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | SSRF policy helpers | Host allowlist and private-network policy helpers |
| `plugin-sdk/ssrf-runtime` | SSRF runtime helpers | Pinned-dispatcher, guarded fetch, SSRF policy helpers |
| `plugin-sdk/system-event-runtime` | System event helpers | `enqueueSystemEvent`, `peekSystemEventEntries` |

View File

@@ -161,7 +161,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, sensitive text redaction, constant-time secret comparison, and secret-collection helpers |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, root-bounded file/path helpers including create-only writes, sync/async atomic file replacement, sibling temp writes, cross-device move fallback, private file-store helpers, symlink-parent guards, external-content, sensitive text redaction, constant-time secret comparison, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-dispatcher` | Narrow pinned-dispatcher helpers without the broad infra runtime surface |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, SSRF error, and SSRF policy helpers |
@@ -210,7 +210,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/param-readers` | Common tool/CLI param readers |
| `plugin-sdk/tool-payload` | Extract normalized payloads from tool result objects |
| `plugin-sdk/tool-send` | Extract canonical send target fields from tool args |
| `plugin-sdk/temp-path` | Shared temp-download path helpers |
| `plugin-sdk/temp-path` | Shared temp-download path helpers and private secure temp workspaces |
| `plugin-sdk/logging-core` | Subsystem logger and redaction helpers |
| `plugin-sdk/markdown-table-runtime` | Markdown table mode and conversion helpers |
| `plugin-sdk/model-session-runtime` | Model/session override helpers such as `applyModelOverrideToSessionEntry` and `resolveAgentMaxConcurrent` |

448
docs/refactor/fs-cleanup.md Normal file
View File

@@ -0,0 +1,448 @@
---
title: "fs-safe Cleanup Plan"
summary: "Plan for consolidating OpenClaw filesystem helpers around @openclaw/fs-safe"
read_when:
- You are refactoring OpenClaw filesystem helpers
- You are changing @openclaw/fs-safe imports, wrappers, or plugin SDK file APIs
- You are deciding whether a local file helper belongs in OpenClaw or fs-safe
---
## Status
Implemented on `codex/extract-fs-safe-primitives`. Keep this file as the
cleanup checklist for follow-up reviews and future fs-safe surface changes.
## Goal
Make OpenClaw's filesystem access boring and predictable:
- Core code uses one small set of OpenClaw wrappers that apply OpenClaw policy.
- Plugin SDK compatibility aliases stay deliberate and documented.
- fs-safe keeps a small public story centered on `root()`, with lower-level
primitives behind explicit subpaths.
- Duplicate JSON, temp, private-store, and path helper names disappear from
OpenClaw internals.
- Security-sensitive behavior keeps regression tests before names move.
## Non-goals
- Do not remove public plugin SDK exports in this cleanup. Keep deprecated
aliases until a versioned SDK migration removes them.
- Do not make fs-safe a sandbox. It remains a library guardrail for local file
access, not OS isolation.
- Do not convert all absolute-path reads to root-bounded reads. Some OpenClaw
paths are trusted absolute paths and should stay explicit.
- Do not chase cosmetic import churn without reducing helper count or clarifying
trust boundaries.
## fs-safe Package Pin
`@openclaw/fs-safe` is published on npm and consumed through a semver range.
Fresh checkouts and CI runners should install the package from the public
registry, not from a local `link:../fs-safe` checkout or a GitHub tarball.
Current range:
- `^0.1.0`
The published package ships built `dist` files, so OpenClaw should not list it
in `pnpm.onlyBuiltDependencies`.
## Current Shape
fs-safe's main entry is intentionally narrow:
- `root`
- `FsSafeError`
- `categorizeFsSafeError`
- root option/result types
- Python helper configuration
The wider surface lives behind subpaths:
- `/json`
- `/store`
- `/temp`
- `/atomic`
- `/root`
- `/advanced`
- `/archive`
- `/walk`
OpenClaw now keeps fs-safe behind a small wrapper boundary:
- local `src/infra/*` wrappers for core policy defaults
- public plugin SDK aliases, including older names from before fs-safe
- package-local utility exports where importing `src/infra` would cross a
package boundary
An import-boundary test rejects new direct fs-safe imports outside those
allowed areas.
## Usage Map
### Root-bounded access
Representative use:
- `src/gateway/server-methods/agents.ts`
- `src/agents/pi-tools.read.ts`
- `src/agents/apply-patch.ts`
- `src/plugins/install.ts`
- `src/auto-reply/reply/stage-sandbox-media.ts`
- `src/gateway/canvas-documents.ts`
Keep this family. `root()` is the fs-safe product surface OpenClaw should push
callers toward.
### JSON helpers
OpenClaw still uses many names for the same operations:
- `readJsonFile`
- `readJsonFileStrict`
- `readDurableJsonFile`
- `writeJsonAtomic`
- `loadJsonFile`
- `saveJsonFile`
- `readJsonFileWithFallback`
- `writeJsonFileAtomically`
fs-safe's canonical names are clearer:
- `tryReadJson`
- `readJson`
- `readJsonIfExists`
- `writeJson`
- `readJsonSync`
- `tryReadJsonSync`
- `writeJsonSync`
This was the highest-value cleanup because it removed naming drift without
changing semantics. Compatibility aliases stay in `src/infra/json-files.ts` and
plugin SDK barrels.
### Private state and stores
Representative use:
- `src/commitments/store.ts`
- `src/agents/models-config.ts`
- `src/agents/pi-auth-json.ts`
- `src/cron/run-log.ts`
- `src/secrets/shared.ts`
- `src/infra/device-auth-store.ts`
- `src/infra/device-identity.ts`
Current overlap:
- `fileStore`
- `fileStore({ private: true })`
- plugin SDK private-state aliases
The concepts are now one family. fs-safe exposes private mode through
`fileStore({ private: true })`; OpenClaw internals and bundled plugins use
store-shaped wrappers instead of standalone private JSON/text helpers.
### Temp workspaces
Representative use:
- `src/media/qr-image.ts`
- `extensions/discord/src/send.voice.ts`
- `extensions/discord/src/voice/audio.ts`
- `extensions/qa-lab/src/temp-dir.test-helper.ts`
`tempWorkspace` is the stable useful primitive. One-shot temp targets and
sibling-temp helpers are lower-level implementation tools.
### Atomic writes
Representative use:
- config and session stores
- cron stores
- plugin install paths
- extension state files
Keep atomic replacement as a public fs-safe subpath. OpenClaw should use the
same canonical JSON/text helpers where possible instead of hand-picking lower
level atomic calls for ordinary JSON state.
### Regular, secure, and root file reads
These are not true duplicates:
- `root()` protects root-relative untrusted paths.
- regular-file helpers read trusted absolute paths with regular-file checks.
- secure-file helpers add ownership and mode checks for secret references.
Keep them separate. Document the trust boundary instead of hiding it behind one
generic "read file" helper.
### Archive helpers
Representative use:
- plugin install
- skill install
- marketplace and ClawHub archive flows
Keep as a separate fs-safe subpath. Do not leak archive entry plumbing into
OpenClaw core call sites unless the caller is actually validating archive
metadata.
## Target Design
### OpenClaw imports
Core OpenClaw code should use local policy wrappers:
- `src/infra/fs-safe.ts` for common root/error helpers
- `src/infra/json-files.ts` for the temporary JSON compatibility layer
- `src/infra/private-file-store.ts` until private stores are unified
- `src/infra/replace-file.ts` for low-level atomic replacement
- `src/infra/boundary-file-read.ts` for loader/package boundary reads
- `src/infra/archive.ts` for archive extraction policy
- `src/infra/file-lock-manager.ts` for the rare core service that needs
manager-style lock lifecycle/diagnostics
New direct imports from `@openclaw/fs-safe/*` should be reserved for:
- package-level utilities outside core that cannot import `src/infra`
- compatibility shims
- code that intentionally consumes a narrow fs-safe subpath, such as
`openclaw/plugin-sdk/file-lock` using `@openclaw/fs-safe/file-lock`
### Plugin SDK exports
Plugin SDK exports are contractual. Keep aliases even when OpenClaw internals
move to canonical names.
Mark older names as deprecated in types/docs when the replacement is stable:
- `readJsonFileWithFallback` -> `readJsonIfExists` or a store method
- `writeJsonFileAtomically` -> `writeJson`
- `loadJsonFile` -> `tryReadJson`
- `saveJsonFile` -> `writeJson`
- `readFileWithinRoot` -> `root(...).read*`
- `writeFileWithinRoot` -> `root(...).write`
### fs-safe stores
Move toward one store family:
```ts
const store = fileStore({
rootDir,
private: true,
mode: 0o600,
dirMode: 0o700,
});
```
or a thin alias:
```ts
const store = stateStore({ rootDir, private: true });
```
The store family should cover:
- `read`
- `readText`
- `readJson`
- `readTextIfExists`
- `readJsonIfExists`
- `write`
- `writeJson`
- `remove`
- `exists`
- `open`
- `copyIn`
- `writeStream`
- `pruneExpired`
This cleanup added that store shape in fs-safe, removed the unshipped
`privateStateStore` surface, and moved OpenClaw internals and bundled plugins
onto explicit store reads/writes.
### Temp
Keep stable public temp surface small:
```ts
await using workspace = await tempWorkspace({ prefix: "openclaw-" });
const target = workspace.path("payload.bin");
```
Move one-shot temp target helpers and sibling-temp helpers to advanced/internal
unless a concrete OpenClaw caller needs the public contract.
## Refactor Phases
### Phase 1: Inventory and Guards
- Add a small import-boundary test that lists allowed direct
`@openclaw/fs-safe/*` imports in OpenClaw core.
- Add regression tests for the JSON symlink behavior kept by
`src/infra/json-file.ts`.
- Add regression tests for public plugin SDK aliases that must keep resolving.
- Add a doc note to the plugin SDK runtime docs once aliases are marked
deprecated.
Exit criteria:
- The current compatibility surface is executable-tested.
- New direct fs-safe imports are visible in review.
### Phase 2: JSON Name Cleanup
- Convert OpenClaw internal callers from old JSON names to canonical fs-safe
names where the semantics are identical.
- Keep plugin SDK aliases unchanged.
- Collapse `src/infra/json-file.ts` and `src/infra/json-files.ts` into one
compatibility module if that reduces indirection without losing symlink
semantics.
- Keep `saveJsonFile` symlink-target behavior until every caller/test is
intentionally migrated.
Exit criteria:
- Core internal code no longer imports `readJsonFileStrict`,
`readDurableJsonFile`, or `writeJsonAtomic` unless it is a compatibility shim.
- Plugin SDK aliases still pass import/type tests.
### Phase 3: Store Unification
- Add the unified private mode to fs-safe's store API.
- Remove the unshipped `privateStateStore` surface instead of keeping a second
store family.
- Migrate OpenClaw private-state internals to the unified store shape in small
groups:
- auth/profile state
- device identity and device auth
- cron/run logs
- commitments
- extension state
- Regenerate the plugin SDK API baseline for the intentional pre-release
private-helper removal.
Exit criteria:
- OpenClaw internals and bundled plugins do not call standalone private
JSON/text helpers.
- `fileStore({ private: true })` is the only private multi-file store API.
### Phase 4: Temp Simplification
- Replace OpenClaw one-shot temp target call sites with `tempWorkspace`.
- Keep `resolvePreferredOpenClawTmpDir` as OpenClaw policy.
- Move one-shot temp and sibling-temp helpers out of the curated OpenClaw
wrapper surface.
Exit criteria:
- OpenClaw uses `tempWorkspace` for temporary file lifetimes unless a low-level
atomic helper owns the temp path.
### Phase 5: Shim Reduction
- Group one-line fs-safe shims into a smaller number of named OpenClaw policy
modules.
- Delete shims that are no longer imported.
- Keep shims that preserve public SDK names or OpenClaw-specific defaults.
Candidate stable shims:
- `src/infra/fs-safe.ts`
- `src/infra/json-files.ts`
- `src/infra/private-file-store.ts`
- `src/infra/replace-file.ts`
- `src/infra/boundary-file-read.ts`
- `src/infra/archive.ts`
Candidate advanced-only grouping:
- path guards
- symlink parent guards
- hardlink guards
- move-path helpers
- file identity helpers
- sibling temp helpers
Exit criteria:
- The local wrapper list has policy meaning, not one file per fs-safe module.
### Phase 6: fs-safe Public Surface Finalization
- Keep `@openclaw/fs-safe` main entry curated.
- Keep `root()` as the primary README/API story.
- Keep `openPinnedFileSync` internal. Use `readSecureFile`, `root().open`, or
`openRootFile*` wrappers instead of exposing the fd-level pinned primitive.
- Keep `createSidecarLockManager` internal. Public callers should use
`acquireFileLock` / `withFileLock`; `createFileLockManager` is subpath-only
for long-lived services that need held-lock inspection or drain/reset.
- Move rare root escape hatches such as `openWritable` to advanced only if API
checks show no supported caller needs the main root interface.
- Keep `regular-file`, `secure-file`, archive, and root helpers separate
because their trust models differ.
- Remove or mark unstable any standalone helper that is fully covered by root or
store methods.
Exit criteria:
- fs-safe has a stable pre-1.0 public surface.
- OpenClaw imports only stable fs-safe APIs outside compatibility shims.
## Verification
Use targeted proof per phase:
- JSON cleanup:
- JSON symlink tests
- plugin SDK JSON-store import tests
- representative extension tests that use JSON store aliases
- Store unification:
- private mode tests in fs-safe
- auth profile persistence tests
- device identity tests
- cron/run-log tests
- Temp cleanup:
- media temp tests
- Discord voice temp tests
- QA-lab temp helper tests
- Shim reduction:
- plugin SDK API generation/check
- import-boundary tests
- `pnpm build`
Before merging a broad cleanup batch, run the changed gate and build:
```sh
pnpm check:changed
pnpm build
```
Implementation proof from this cleanup:
- `pnpm test src/infra/fs-safe-import-boundary.test.ts src/plugin-sdk/temp-path.test.ts src/agents/models-config.write-serialization.test.ts src/infra/json-file.test.ts src/infra/json-files.test.ts`
- `pnpm test src/infra/fs-safe-import-boundary.test.ts src/infra/device-auth-store.test.ts src/infra/device-identity.test.ts src/infra/exec-approvals.test.ts src/agents/models-config.write-serialization.test.ts src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts src/agents/harness/native-hook-relay.test.ts`
- `pnpm test src/infra/fs-safe-import-boundary.test.ts src/infra/hardlink-guards.test.ts src/infra/file-identity.test.ts src/plugin-sdk/fs-safe-compat.test.ts src/plugin-sdk/temp-path.test.ts`
- `pnpm plugin-sdk:api:check`
- `pnpm build`
- Blacksmith Testbox `pnpm install --frozen-lockfile --config.minimum-release-age=0 && pnpm check:changed`
- In `../fs-safe`: `pnpm docs:site && pnpm build && pnpm test test/api-coverage.test.ts test/new-primitives.test.ts`
## Review Checklist
- Does this change reduce a public name, local wrapper, or duplicated semantic
family?
- Is the old name public plugin SDK surface? If yes, keep a deprecated alias.
- Does the replacement preserve symlink, hardlink, mode, and missing-file
behavior?
- Is the caller using an untrusted relative path, trusted absolute path, secret
path, archive entry, or temp lifetime? Pick the helper that says that out
loud.
- Are docs and plugin SDK API snapshots updated when exported names change?