Permissions
@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.
import {
formatPermissionDetail,
formatPermissionRemediation,
inspectPathPermissions,
} from "@openclaw/fs-safe/permissions";
const perms = await inspectPathPermissions("/var/lib/app/auth.token");
console.log(formatPermissionDetail("/var/lib/app/auth.token", perms));
if (perms.ok && (perms.groupReadable || perms.worldReadable)) {
console.log(
formatPermissionRemediation({
targetPath: "/var/lib/app/auth.token",
perms,
isDir: false,
posixMode: 0o600,
}),
);
}
#POSIX helpers
safeStat(path);
inspectPathPermissions(path, options?);
formatPermissionDetail(path, check);
formatPermissionRemediation({ targetPath, perms, isDir, posixMode });
modeBits(mode);
formatOctal(bits);
isWorldWritable(bits);
isGroupWritable(bits);
isWorldReadable(bits);
isGroupReadable(bits);
POSIX remediation strings shell-quote paths with whitespace or metacharacters and protect option-like paths with --, so they can be presented as commands without letting the inspected pathname add shell syntax.
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 and also reports ownerSid plus ownerTrusted when ownership can be verified. ownerTrusted is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. This remains a pathname reporting API with the fallbacks described below. readSecureFile() obtains descriptor-bound owner/DACL facts for the exact handle it reads, using native support or the packaged prebuilt .NET Framework helper in auto and off modes.
#Advanced Windows ACL helpers
Private Windows DACLs grant full access to the process user, SYSTEM, and Administrators. Inside an AppContainer they additionally grant that process token's own container SID, with inheritance on directories. Other containers and ALL APPLICATION PACKAGES receive no grant. Creation and security inspection use the same token identity; a token-query failure rejects the operation. Outside an AppContainer the existing descriptors are unchanged. The native addon and packaged .NET security helper both enforce this boundary.
When an AppContainer cannot obtain DOS volume names, guarded path operations require the native addon. fs-safe resolves the same open handle's normalized NT name, then verifies its drive-root prefix before returning the DOS spelling. UNC names are translated from the NT network namespace. This preserves long-name canonicalization and the existing identity and containment checks without granting access to other containers. Drive mappings are queried on each operation. Canonicalization resolves relative inputs against the process working directory before opening, and UNC inputs accept either Windows path separator spelling. APIs that require absolute paths, including readSecureFile(), retain that requirement. The current container SID is trusted during ACL inspection independently of the object's owner. Folder-mounted volumes, volume-GUID inputs, and unmatched drive prefixes fail closed with FsSafeError("path-mismatch") when DOS resolution is denied. AppContainer support requires the native addon. In native mode off, or without native membership support, a denied DOS-name query retains Node's original EPERM error unchanged. The JavaScript realpath resolver is not a fallback. Outside a sandbox, successful DOS resolution and ordinary errors retain their existing behavior, including in native mode off.
Only a denied DOS-name query triggers the canonicalizer's in-process token probe. The addon caches immutable AppContainer membership across Workers. Canonicalization never starts a helper process. In a container, failure to open the path or query its normalized NT name also preserves the original error, so existing contention handling remains effective. Token-probe failures are typed.
Inside an AppContainer, advanced ACL inspection uses native facts for inherited entries and unique full-control allow entries with uniform inheritance flags, including fs-safe's private DACL. Operations that require an external helper instead fail with helper-unavailable when native membership identifies the container. Current Node/libuv child-process pipe creation can fail or hang inside AppContainers (nodejs/node#66374, libuv/libuv#5178); fs-safe must not start the .NET helper or a Node helper child there.
The low-level Windows ACL parser and icacls command builders live in @openclaw/fs-safe/advanced:
import {
createIcaclsResetCommand,
formatIcaclsResetCommand,
formatWindowsAclSummary,
inspectWindowsAcl,
parseIcaclsOutput,
resolveWindowsUserPrincipal,
summarizeWindowsAcl,
} from "@openclaw/fs-safe/advanced";
inspectWindowsAcl(path, { env, exec });
parseIcaclsOutput(output, targetPath);
summarizeWindowsAcl(entries, env);
formatWindowsAclSummary(summary);
formatIcaclsResetCommand(targetPath, { isDir, env });
createIcaclsResetCommand(targetPath, { isDir, env });
resolveWindowsUserPrincipal(env);
The fallback Windows inspector reads the owner and DACL together through one built-in prebuilt .NET Framework helper query. It runs the packaged windows-security-helper.exe with arguments ["owner", targetPath], without starting PowerShell. The query preserves .NET's named-path security lookup and ACL normalization, including leaf-link behavior. It returns canonical SIDs and numeric access masks, so Unicode paths and account names do not pass through lossy console display text. inspectWindowsAcl() uses native descriptor facts for complete local ACLs with nonzero inherited ACEs (or empty/null DACLs) when the optional Windows binding is available. It applies the same classifier to native facts and the fallback query, returning canonical SIDs in its principal fields with normalized rights tokens. An explicit exec option retains the query path; env no longer selects a query executable or bypasses native inspection. Disabled or unavailable native helpers, remote or incomplete descriptors, leaf symbolic links, and native query errors use the fallback. Explicit ACEs and zero-mask entries also retain the query so .NET continues to own its ACE ordering and normalization. Structured ACLs containing only canonical SIDs are classified directly from the current-user SID without requiring a separate account-name lookup. The advanced options retain currentUserSid as an explicit classification override and principalTranslationFailed: true as an immediate unverified result. The query returns SIDs directly; no translation cache is needed. Injected executors must return the same structured success JSON as the built-in query: valid ownerSid and currentUserSid strings, an explicit boolean remote, and complete DACL facts (complete, daclPresent, and aces). Pass-through executors can run the supplied helper command and arguments directly; canned-JSON executors use the same parser. env remains available for principal classification and remediation command construction. Missing or nonboolean locality leaves both inspectors unverified; only remote: false establishes locality for owner trust. An explicit remote: true retains the ACL report but never grants trusted ownership. The existing classifier assigns principals to trusted, world, or group; trusted defaults include the current user, SYSTEM, and Administrators. The built-in query has a fixed 30-second process deadline. A command failure or timeout returns an unverified result (source: "unknown") so callers fail closed. Advanced callers that inject a custom exec implementation own that executor's deadline. Failed owner and ACL inspections retain error text and an optional errorDetail: PermissionCommandFailure with command, integer durationMs, timedOut, exitCode, signal, and stderr. The type is exported from both @openclaw/fs-safe/permissions and @openclaw/fs-safe/advanced. Built-in execution measures elapsed time; injected execFile-shaped failures receive best-effort command diagnostics. Plain errors have no errorDetail. Display reasons and stderr escape control characters and are limited to 400 characters, including a trailing … when truncated. Diagnostics do not copy stdout or read target file contents. The separate errorCause retains the original exception for restricted local diagnosis; do not serialize or expose it as display text. Custom executors may reject with any JavaScript value. The fallback display formatter handles primitives directly and reads only string-valued name and message data descriptors through a small, fixed prototype budget. It does not coerce objects, invoke accessors, or inspect proxy targets; unavailable display facts use a bounded generic reason. Command fields follow the same best-effort data-descriptor rule. Raw string, Buffer, or genuine Uint8Array stderr retains the sanitization above. Byte stderr is copied through captured typed-array intrinsics into a private bounded snapshot before replacement-based UTF-8 decoding; receiver properties, iterators, constructors, and altered prototypes are not consulted. Detached or out-of-bounds byte views contribute no stderr detail. These diagnostic limits do not relax permission policy: incomplete owner or ACL inspection remains unverified, and errorCause remains the exact rejected value even when no display metadata is safe to obtain. The parser and remediation command builders remain on the advanced surface for CLIs processing captured icacls output or presenting an explicit repair. Runtime inspection does not parse that display text. A null DACL reports unrestricted access; an empty DACL grants nothing. Inherit-only ACEs do not apply to the inspected object, and deny ACEs never subtract coarse grants or claim effective-access evaluation. Unsupported ACE layouts remain unverified.
When the native binding is available, inspectPathPermissions() reads the owner and DACL directly with Windows security APIs. It classifies the current user, LocalSystem, and built-in Administrators as trusted and reports the world/group read/write facts consumed by secure reads. Descriptor forms it cannot classify equivalently fall back to the structured .NET query; mode: "off" exercises that fallback deterministically.
#Policy-free owner and DACL facts
readOwnerAndDacl() exposes the direct Windows descriptor facts needed by a consumer that owns a principal allowlist. It deliberately does not decide which SID is trusted or calculate effective access. For example, snapshot staging can reject an incomplete descriptor and ignore inherit-only ACEs before applying its own exact SID policy:
import { readOwnerAndDacl } from "@openclaw/fs-safe/permissions";
const facts = readOwnerAndDacl(stagingDirectory);
if (facts.status === "unsupported-platform") {
throw new Error(`Windows ACL facts unavailable on ${facts.platform}`);
}
if (!facts.isLocal || !facts.daclPresent || !facts.complete) {
throw new Error("staging DACL cannot be evaluated completely");
}
for (const ace of facts.aces) {
if (ace.flags.inheritOnly) continue;
if (!trustedSids.has(ace.sid)) {
throw new Error(`unexpected staging principal: ${ace.sid}`);
}
evaluateMaskAndDenyOrder(ace.aceType, ace.mask);
}
On Windows the supported result contains ownerSid, currentUserSid, daclPresent, isLocal, complete, unsupportedAceTypes, and ordered basic allow/deny aces. currentUserSid is the process token's TokenUser SID, so callers can compare it with the owner or their own allowlist without fs-safe applying trust policy. Each ACE has { sid, mask, aceType, flags }; flags retains the raw byte and decoded objectInherit, containerInherit, noPropagateInherit, inheritOnly, inherited, successfulAccess, and failedAccess facts. SID strings are lowercase Windows SID notation. daclPresent: false represents a null DACL, which grants unrestricted access; it must not be mistaken for an empty DACL.
The optional isVolumeRoot fact is true only when Windows resolves the inspected handle to an exact normalized local volume GUID root and a second no-follow handle proves the named directory is the same object without a reparse point. This distinguishes the volume-root object from a replaceable mounted-folder or junction entry pointing at it. Those reparse entries remain false; inspect a canonical non-reparse volume-root path to obtain a positive result. The entry lookup normalizes trailing separators and current-directory components separately from ACL inspection; parent traversal cannot assert a volume root. Ordinary directories, drive aliases pointing at a subdirectory, failed identity or normalization queries, and remote/UNC paths are also false. Older native helpers omit the fact, so consumers must require isVolumeRoot === true before using it. This metadata does not relax reparse-point or ACL admission policy: consumers still decide which rights are acceptable, and all ACE masks are returned unchanged.
Object-specific and other ACE layouts are not guessed: they are omitted, complete becomes false, and their numeric types appear in unsupportedAceTypes, allowing a security-sensitive caller to fail closed. Non-Windows systems return { status: "unsupported-platform", platform }. Windows prefers the native binding. In native auto or off mode, a missing binding or capability uses the packaged prebuilt .NET Framework helper with the same raw ACE projection. Native require rejects either absence with FsSafeError("helper-unavailable") and starts no command. An available native query's failure is terminal. The existing coarse inspectPathPermissions() API still owns its compatibility fallback and trust classification.
#Asynchronous batches
Use readOwnerAndDaclBatch() when several paths, such as a directory and its ancestors, need inspection without blocking the caller's event loop:
import { readOwnerAndDaclBatch } from "@openclaw/fs-safe/permissions";
const facts = await readOwnerAndDaclBatch(stagingDirectories, { timeoutMs: 60_000 });
function readOwnerAndDaclBatch(
paths: readonly string[],
options?: { timeoutMs?: number },
): Promise<OwnerAndDaclResult[]>;
Results use the same raw fact shape and SID spelling as readOwnerAndDacl(). They correspond one-for-one to input order, including duplicate paths. An empty array returns [] without dispatch. Other platforms return an unsupported-platform result for each path. Query failures, malformed facts, or missing/reordered response rows reject the entire batch; no partial facts are returned. Null DACLs, incomplete ACE lists and nonlocal observations remain raw facts for the caller's policy to evaluate.
The call captures paths, options and native mode before awaiting. Windows relative paths are anchored to the current directory or selected drive at entry, without normalizing their remaining . or .. components. Empty, nonstring, sparse or NUL-containing path entries and Windows namespace aliases reject before dispatch. The UTF-8 JSON input is limited to 16 MiB.
An available native capability runs all queries in one isolated process using the current runtime executable. Its resolved native mode is forwarded explicitly; Node preload and module-search environment overrides are not inherited. An available native query's failure is terminal. In auto when the binding or capability is absent, or in off, one packaged .NET Framework helper process reads the path array from stdin. Native require rejects missing support before launching a process. No route launches one process per path or wraps synchronous parent-process queries in promises.
timeoutMs defaults to 60,000 and applies to the whole process, including helper startup. It must be an integer from 1 through 2,147,483,647. Combined stdout and stderr are limited to 16 MiB. Success waits for process exit and both output pipes to close. Timeout and transport failure request termination, then retain the existing one-second settlement grace. The error's processExitConfirmed field, also retained in its cause receipt, distinguishes observed exit from an unconfirmed termination attempt. An unconfirmed child can still be reading after rejection; the operation returns no facts and owns no output files or artifact cleanup. Neither timing out nor receiving a successful kill request is reported as confirmed exit.
The helper fallback encodes and budgets each result while collecting it, instead of retaining every descriptor graph until the entire batch finishes. An encoded response that would exceed 16 MiB rejects with too-large before inspecting later paths. A query failure observed earlier retains its original error, and no partial facts are returned. Duplicate paths remain independent observations. This bounds accumulated encoded results, not total process memory: the current descriptor and row, decoded input, and runtime overhead still exist.
Batch observations are point-in-time pathname facts, not a snapshot or a retained filesystem capability. The caller still owns ancestry trust, principal policy and authorization for subsequent operations. Existing singular queries and other command operations retain their 30-second deadline, 1 MiB output budget and documented failure behavior.
#Private directories
import path from "node:path";
import { createPrivateDirectory } from "@openclaw/fs-safe/permissions";
const sqliteDirectory =
"C:\\Users\\me\\AppData\\Local\\OpenClaw\\private-databases";
await createPrivateDirectory(sqliteDirectory);
await openSqlite(path.join(sqliteDirectory, "sessions.sqlite"));
On Windows, this creates the directory and applies a protected owner + LocalSystem + Administrators full-control DACL directly with an atomic security descriptor. The native route launches no command. When its binding or capability is unavailable, native auto and off modes use the packaged prebuilt .NET Framework helper. Both routes retain the parent and exact created-directory handles through ACL and final pathname validation. If validation fails, it attempts only nonrecursive deletion through the created handle, preserving any pathname replacement. If cleanup also fails, the error retains the original failure and includes the cleanup failure.
Directory association checks compare the complete 64-bit volume serial and 128-bit FILE_ID_INFO identity, including on ReFS. If that identity class is unavailable, the operation fails closed without a narrower file-index fallback. Validation confirms that the created directory is local, its DACL is protected from inheritance, and its final public pathname opens the same local directory.
This is a point-in-time pathname association check. The function closes its handles before returning; callers must keep the pathname's ancestry trusted during subsequent use, including opening SQLite databases in the example above. The immediate parent and final directory must not be reparse points. Earlier ancestor reparse points can be followed; this API does not reject every reparse point in the full ancestry.
Path components ending in a space or period are rejected before filesystem operations to avoid differing Win32 and native pathname interpretations. This also rejects explicit . and .. components, including spellings such as .\private and parent\..\private, as a compatibility restriction. Simple relative names without these components remain supported.
This API is Windows-only; it fails closed with FsSafeError("helper-unavailable") on other platforms. Native require also fails if the binding or capability is missing and never starts a command. An available native operation's failure is terminal. POSIX callers should create private directories through their existing trusted-root creation policy rather than a pathname-only compatibility shim. Existing Windows permission inspection still retains its structured .NET compatibility fallback.
The raw owner/DACL and private-directory fallbacks each emit one path-free FS_SAFE_NATIVE_FALLBACK warning per process. A fresh prebuilt helper process adds startup overhead to each call; install the native package for frequent operations. These routes run the packaged executable under normal system policy; see the Windows security fallback prerequisites. If command support is unavailable, disallowed, or fails, the operation rejects. Private-directory creation never falls back to inherited permissions. The asynchronous creation command has a 30-second deadline. After a timeout or transport failure, fs-safe requests termination and waits at most one further second before rejecting and closing its output pipes. The error distinguishes observed process exit from an unconfirmed termination attempt. If the OS refuses termination, the command can still create the directory after rejection. An already-created object retains its protected DACL, but pathname validation and owned-handle cleanup may not finish. An error therefore does not prove the pathname is absent; a retry can report EEXIST. Before retrying or using the pathname, establish that the earlier operation stopped and verify any existing directory's security. The library does not attempt pathname-based cleanup.
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. These builders and formatPermissionRemediation() throw helper-unavailable in a known AppContainer: removing inherited grants could revoke the container's own access. Ordinary-process commands remain unchanged.
#Types
type PermissionCheck = {
ok: boolean;
isSymlink: boolean;
isDir: boolean;
mode: number | null;
bits: number | null;
source: "posix" | "windows-acl" | "unknown";
worldWritable: boolean;
groupWritable: boolean;
worldReadable: boolean;
groupReadable: boolean;
ownerSid?: string;
ownerTrusted?: boolean;
ownerError?: string;
aclSummary?: string;
error?: string;
};
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.
#See also
- Secure file reads — fd-pinned reads that enforce these checks.
- Errors — permission-related
FsSafeErrorcodes. - Native architecture — direct Windows security descriptor mechanisms.
- Migrating to 0.5 — native-only feature checklist.