Specialized

Secret files

Secret files

Helpers for reading and writing credentials. Files are written at mode 0o600, dirs at 0o700, with a maximum read size to avoid OOM on bogus input.

import {
  createSecretFileAtomic,
  readSecretFile,
  readSecretFileSync,
  tryReadSecretFile,
  tryReadSecretFileSync,
  writeSecretFileAtomic,
  DEFAULT_SECRET_FILE_MAX_BYTES,
  PRIVATE_SECRET_DIR_MODE,
  PRIVATE_SECRET_FILE_MODE,
} from "@openclaw/fs-safe/secret";

#When to use these vs writeJson

Use these whenUse writeJson when
The file is a credential (token, key, password).The file is application state.
You want the parent directory created at 0o700 if missing.You don't care about the parent directory mode.
You want a hard size cap on reads (to defend against bogus input).You're reading bounded JSON state.
Mode 0o600 and the private parent policy are mandatory.Standalone writeJson also defaults to 0o600, but it does not enforce the secret-directory ownership and permission policy.

#Constants

DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;  // 16 KiB
PRIVATE_SECRET_DIR_MODE = 0o700;
PRIVATE_SECRET_FILE_MODE = 0o600;

The 16 KiB cap is intentionally aggressive — credentials should be small. If you need bigger, pass maxBytes explicitly.

#Reading

#tryReadSecretFileSync(filePath, label, options?)

The missing-is-optional reader. It returns the trimmed secret string, or undefined when the filePath argument is absent/blank or the target does not exist. An existing empty file is invalid and throws, as do unreadable, oversized, symlink, hardlink, and other validation failures.

import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";

const token = tryReadSecretFileSync("/var/lib/app/auth.token", "auth token");
if (token) {
  useToken(token);
} else {
  await reauthenticate();
}

#readSecretFileSync(filePath, label, options?)

Strict reader. Throws FsSafeError when the file is missing, too large, empty, unreadable, or rejected by the validation checks. Use when failing loudly is the right call:

const token = readSecretFileSync("/var/lib/app/auth.token", "auth token");

#Read options

type SecretFileReadOptions = {
  maxBytes?: number;         // default DEFAULT_SECRET_FILE_MAX_BYTES (16 KiB)
  rejectSymlink?: boolean;   // default false
  rejectHardlinks?: boolean; // default true
};

maxBytes must be a non-negative safe integer or positive Infinity; zero is an active cap, Infinity disables it, and omitted or explicitly undefined values preserve the 16 KiB default.

The reader trims the file content and rejects empty results. Symlink paths are followed and pinned by default; set rejectSymlink: true when the pathname itself must not be an alias. Hardlinks are rejected by default so another in-tree name cannot alias the credential; pass rejectHardlinks: false only when you explicitly trust that layout.

Read options are captured when the call starts. Mutating a shared options object while an asynchronous read is in flight cannot relax its link policy.

These readers do not enforce ownership or mode bits on an existing file. Their read contract covers pinned identity, file type, link policy, and byte bounds; the 0o600 guarantee belongs to the write helpers below. Use readSecureFile when reading an externally managed credential must also fail on broad permissions or unexpected ownership.

readSecretFile() and tryReadSecretFile() are asynchronous counterparts with the same pinned-handle validation, byte cap, trimming, error codes, and strict versus missing-is-undefined naming semantics.

Both readers reject known unsafe device and process-fd paths with device-path before inspection, and check the resolved target before opening it. This includes Windows reserved device names and ignored-space aliases such as nul .txt. Optional readers propagate this error instead of treating the secret as missing.

Both sync and async readers compare lossless bigint identities from the preview, opened descriptor, resolved target, and current input path before reading. POSIX opens are nonblocking, so a raced FIFO is rejected by descriptor type instead of waiting for a writer. An allowed symlink must still point to the opened file. On Windows, a zero device or inode is unverified: that inspection is retried once without reopening the file, preserving known identity components and link checks. Definite mismatches and persistent ambiguity fail with path-mismatch; optional reads do not treat these failures as missing files.

If an already validated descriptor fails while reading, both readers throw an operational FsSafeError with code: "read-failed"; inspect cause for the underlying Node filesystem code such as EIO.

Use the async strict reader when a service cannot start safely without the credential:

import { readSecretFile } from "@openclaw/fs-safe/secret";

const signingKey = await readSecretFile(
  "/var/lib/app/keys/webhook-signing.key",
  "webhook signing key",
  { maxBytes: 8 * 1024, rejectSymlink: true },
);
startWebhookVerifier(signingKey);

#Writing

#writeSecretFileAtomic(params)

Async. Creates the parent directory at dirMode (default 0o700) if missing, writes content to a sibling temp file, finalizes mode (default 0o600) through an owned descriptor after content writes, and atomically renames over the destination. Publication verification checks the final file identity and mode.

On POSIX, both native and JavaScript writers verify actual 0o600 permission bits through the retained descriptor before writing content. A filesystem that reports successful chmod without enforcing those bits fails with insecure-permissions before any payload is written, including when an explicit dirMode permits other users to traverse the parent. The requested final mode is still applied after content writes, including restrictive and special-bit overrides. This mode-bit check does not require native ACL inspection; JavaScript secret writes remain available on macOS.

Concurrent writes to distinct leaves may share creation of a missing parent. After a parent-creation race, the helper re-inspects the entry and requires a non-symlink directory, then revalidates root/parent guards, containment, and the requested directory mode before writing either leaf.

Publication verification borrows the writer's still-open descriptor to check the exact file identity, regular-file and link policy, requested POSIX mode, and root/parent ancestry before the writer closes it. All 0o7777 mode bits must match, including explicitly requested special bits; unexpected special bits are rejected. POSIX mode overrides such as 0o000 and 0o200 do not require read permission or a readonly reopen, and verification does not widen the final mode. Windows retains pathname-identity verification without enforcing POSIX mode bits; its native writer checks the reopened descriptor against the original lossless file identity before changing the final mode.

Failed JavaScript fallback writes attempt cleanup while retaining the original descriptor and only after checking parent and file identities. Native cleanup also compares lossless parent and file identities. Unverifiable paths are left for caller-managed cleanup, and cleanup failures do not replace the original write error. These are best-effort identity checks followed by name-based removal, not atomic conditional unlink. A publication-verification failure after a completed write does not authorize deleting the published file.

import { writeSecretFileAtomic } from "@openclaw/fs-safe/secret";

await writeSecretFileAtomic({
  rootDir: "/var/lib/app",
  filePath: "/var/lib/app/auth.token",
  content: token,
});

#Parameters

type WriteSecretFileParams = {
  rootDir: string;             // trusted root directory (created at dirMode if missing)
  filePath: string;             // absolute path; must be inside rootDir
  content: string | Uint8Array;
  mode?: number;                // file mode for the new file (default PRIVATE_SECRET_FILE_MODE = 0o600)
  dirMode?: number;             // mode for the root and intermediate dirs (default PRIVATE_SECRET_DIR_MODE = 0o700)
  durable?: boolean;            // default true; false skips file and parent fsync
};

durable: false preserves atomic publication, modes, and identity checks while skipping file and parent-directory fsync calls. Use it only for reconstructible data where lower latency matters more than crash-durability; private and JSON stores forward their durability policy here.

The full POSIX directory mode is asserted on each component along the path: rootDir, then any intermediate dirs, then the parent. Existing directories, including another creator's EEXIST winner, must already match dirMode exactly or the write fails with insecure-permissions; they are never chmod-repaired. An explicitly requested directory mode such as 0o2750 preserves its setgid bit. Audit and adjust existing secret directories yourself. The admitted directory guards are retained through traversal and the final writer/lock handoff; a fresh pathname lookup cannot silently authorize a replacement. The caller must still trust the selected root and its owners; matching permission bits alone do not establish that trust.

Directory admission and its retained guards use lossless bigint identities, including through private locks and native writes. On Windows, an unknown zero device or inode gets one reinspection that retains known components; a definite mismatch or persistent ambiguity fails with path-mismatch rather than authorizing a replacement.

Both mode options must resolve to integers between 0o0000 and 0o7777; invalid values fail with invalid-path before directory creation or file publication. Windows validates the options but does not enforce POSIX permission bits.

After this operation wins directory creation, initialization uses a pinned descriptor bound to the admitted identity and effective user, with ancestor checks before chmod. It does not chmod the caller's pathname. Creation and descriptor admission are separate operations, not an atomic create-and-pin guarantee. A raced directory that has not reached its requested mode yet is rejected rather than repaired; callers may retry after its creator finishes initialization.

Initialization fails closed if the platform cannot safely pin a created directory. In particular, a non-root macOS process cannot pin a new 000 directory produced by umask(0o777); the write fails without repairing that directory or writing a secret. Restrictive masks retaining owner search permission remain usable. Linux x64/arm64 can use the guarded O_PATH/procfs descriptor route where available. There is no unguarded pathname-chmod fallback, and a failure may leave a created directory for caller-managed cleanup.

#createSecretFileAtomic(params)

This create-only sibling has the same directory, mode, pinned-write, and post-write verification policy. Final materialization uses exclusive create; if anything already occupies the target path it throws FsSafeError("secret-exists") without modifying that entry. Use the distinct name when first-writer-wins is part of the credential protocol.

Distinct leaves can share missing-parent creation without a secret-exists error. Concurrent creates at the same leaf still have exactly one winner; the loser receives secret-exists and leaves the winner's bytes intact.

For example, two onboarding requests may race to install the first refresh token. Exactly one should win, and the loser must not overwrite it:

import { FsSafeError } from "@openclaw/fs-safe/errors";
import { createSecretFileAtomic } from "@openclaw/fs-safe/secret";

try {
  await createSecretFileAtomic({
    rootDir: "/var/lib/app/credentials",
    filePath: "/var/lib/app/credentials/provider.refresh-token",
    content: refreshToken,
  });
} catch (error) {
  if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
  // Another initializer won. Read and validate the installed credential.
}

For more permissive credentials, override mode:

await writeSecretFileAtomic({
  rootDir: "/var/lib/app",
  filePath: "/var/lib/app/readonly.token",
  content: token,
  mode: 0o400, // tighter than the default
});

#Common patterns

#Load on boot, reauthenticate on miss

const token = tryReadSecretFileSync("/var/lib/app/auth.token", "auth token");
if (!token) await runOauthFlow();

#Refresh and persist a token

const fresh = await refreshToken(currentRefresh);
await writeSecretFileAtomic({
  rootDir: "/var/lib/app",
  filePath: "/var/lib/app/auth.token",
  content: JSON.stringify(fresh),
});

#Compose with withTimeout

import { withTimeout } from "@openclaw/fs-safe/advanced";

await withTimeout(
  writeSecretFileAtomic({ rootDir, filePath, content }),
  5_000,
  "persist auth token",
);

#Threat model notes

  • On POSIX, the default 0600 file and 0700 directory modes restrict group and other access. They do not protect against processes with the same UID, root, attackers who can read process memory, or access granted by additional ACL entries.
  • Validation failures are tripwires, not authorization. Investigate before clearing a rejected credential file.
  • On POSIX, a file that still reports a mode other than 0600 after initialization is rejected with insecure-permissions before payload is written. Matching mode reports alone cannot prove that an arbitrary filesystem actually enforces those permissions.

#See also

  • JSON fileswriteJson accepts mode: 0o600 for non-secret JSON state.
  • Atomic writes — the lower-level replaceFileAtomic used by these helpers.
  • Private file-store mode — root-bounded JSON+text stores using secret-file write policy.
  • Migrating to 0.5 — strict/try reads and create-only adoption checklist.