Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions README.md

Large diffs are not rendered by default.

30 changes: 26 additions & 4 deletions README.zh-CN.md

Large diffs are not rendered by default.

33 changes: 18 additions & 15 deletions appcontainer/ContainerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,20 @@ public static void GrantAccess(string containerName, string dirPath, bool readOn
{
var sw = System.Diagnostics.Stopwatch.StartNew();

if (!Directory.Exists(dirPath) && !File.Exists(dirPath))
throw new DirectoryNotFoundException($"Path not found: {dirPath}");

// Do this before recursive ACL propagation or an already-accessible fast path.
try
{
GrantAncestorTraverse(containerName, dirPath);
Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] GrantAncestorTraverse done for: {dirPath}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] WARNING: GrantAncestorTraverse FAILED for {dirPath}: {ex.Message}");
}

// Fast path: if ALL APPLICATION PACKAGES already has sufficient access,
// skip per-profile ACL grant (the directory is accessible to any AppContainer).
if (HasAllAppPackagesAccess(dirPath, readOnly))
Expand Down Expand Up @@ -316,18 +330,6 @@ public static void GrantAccess(string containerName, string dirPath, bool readOn
throw new DirectoryNotFoundException($"Path not found: {dirPath}");
}

// Grant traverse on ancestor directories so the AC process can reach
// this path. Failures are non-fatal (e.g. drive root may need admin).
try
{
GrantAncestorTraverse(containerName, dirPath);
Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] GrantAncestorTraverse done for: {dirPath}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] WARNING: GrantAncestorTraverse FAILED for {dirPath}: {ex.Message}");
}

Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] GrantAccess complete for: {dirPath} (readOnly={readOnly})");

// NOTE: GrantProtectedChildren is NOT called here — it's invoked
Expand Down Expand Up @@ -601,12 +603,14 @@ public static void GrantAncestorTraverse(string containerName, string dirPath)

// Check if the SID already has traverse access
var rules = security.GetAccessRules(true, false, typeof(SecurityIdentifier));
var traverseRights = FileSystemRights.ReadAttributes |
FileSystemRights.ReadExtendedAttributes | FileSystemRights.Traverse;
bool hasAccess = false;
foreach (FileSystemAccessRule rule in rules)
{
if (rule.IdentityReference.Value == sidStr &&
rule.AccessControlType == AccessControlType.Allow &&
(rule.FileSystemRights & FileSystemRights.Traverse) != 0)
(rule.FileSystemRights & traverseRights) == traverseRights)
{
hasAccess = true;
break;
Expand All @@ -627,8 +631,7 @@ public static void GrantAncestorTraverse(string containerName, string dirPath)
// directory contents — e.g. D:\ sibling folder names are private).
security.AddAccessRule(new FileSystemAccessRule(
sid,
FileSystemRights.ReadAttributes |
FileSystemRights.ReadExtendedAttributes | FileSystemRights.Traverse,
traverseRights,
InheritanceFlags.None,
PropagationFlags.None,
AccessControlType.Allow));
Expand Down
23 changes: 23 additions & 0 deletions appcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ Loaded via `NODE_OPTIONS="--require sandbox-preload.js"` into the Gateway proces

### Activation Modes

OpenClaw's internal SQLite snapshot integration recognizes both the 2026.9.x
`openclaw-sqlite-readonly-<pid>-<uuid>` directory format and the 2026.10.2
`openclaw-sqlite-readonly-v2-<uuid>` format under `%LOCALAPPDATA%\openclaw`.
The internal worker exception still requires the exact Node executable, worker
path, trusted runtime caller, supported mode, and a canonical database path
inside the OpenClaw state directory. Redirected or malformed staging roots are
rejected; this does not allow arbitrary Node commands outside the sandbox.
Scoped SQLite sessions validate each IPC request before the runtime handles it;
database requests must stay inside the canonical state directory. The separate
state-ownership reader is restricted to its exact worker, caller, and database
argument. Worker-thread startup uses the package root captured from the desktop
when Node has not initialized the worker's entry arguments yet.

The installer grants read-only access to the actual OpenClaw npm installation
prefix, which may differ from the Node.js directory. Ancestor grants use numeric
SIDs and metadata/traverse rights only. Required grants allow up to five minutes
for Windows ACL propagation. If a shared system directory cannot be changed,
installation must still prove that sandboxed Node can read the installed runtime
and create/remove a file in the sandbox workspace; it fails if that check fails.
Direct Node script arguments retain quoting around paths with spaces. This
prevents startup probes such as `npm root -g` from requesting access to a
nonexistent `C:\Program` path while still inspecting the complete file path.

- **Main gateway** (`OPENCLAW_SANDBOX_BYPASS=1`): Sandbox activates on the next tick after HTTP server starts listening. Pre-listen startup commands (netstat, etc.) run with bypass; once the server is ready, the sandbox activates before any incoming requests are processed.
- **Forked workers** (bypass absent): Sandbox active immediately.

Expand Down
13 changes: 12 additions & 1 deletion appcontainer/path-extraction.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ function extractShellPayload(cmd, args) {
} else {
for (var i = 0; i < argArr.length; i++) {
if (!/^[-\/]/.test(argArr[i])) {
if (bn === "node" && /^[a-zA-Z]:[\\/]/.test(String(argArr[i]))) {
return argArr
.slice(i)
.map(function (arg) {
var value = String(arg);
return /^[a-zA-Z]:[\\/]/.test(value) && /\s/.test(value) && !/["'\r\n]/.test(value)
? '"' + value + '"'
: value;
})
.join(" ");
}
payload = argArr.slice(i).join(" ");
break;
}
Expand Down Expand Up @@ -360,7 +371,7 @@ var READ_PATTERNS = [
// .NET read methods
/\[?System\.IO\.(?:File|Directory)\]?::(?:Read|Get|Exists|Open)\w*\s*\(\s*["']([a-zA-Z]:\\[^"']+)/gi,
// Generic fallback: quoted path
/["']([a-zA-Z]:\\[^"'\s]+)["']/g,
/["']([a-zA-Z]:\\[^"'\r\n]+)["']/g,
// Generic fallback: unquoted path (comma = separator)
/(?:^|[\s,])([a-zA-Z]:\\[^\s|;>"'(),]+)/g,
];
Expand Down
120 changes: 109 additions & 11 deletions appcontainer/sandbox-cp-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var extractShellPayloadFromString = _pathExtraction.extractShellPayloadFromStrin
var filterSystemPaths = _pathExtraction.filterSystemPaths;
var TRUSTED_NODE_EXEC_PATH = process.execPath;
var TRUSTED_OPENCLAW_ENTRY_PATH = process.argv[1] || "";
var TRUSTED_OPENCLAW_PACKAGE_ROOT = process.env.MICROCLAW_OPENCLAW_PACKAGE_DIR || "";
var TRUSTED_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR || "";
var TRUSTED_PRELOAD_PATH = pathMod.join(__dirname, "sandbox-preload.js");
var TRUSTED_WORKER_ENV = (function () {
Expand Down Expand Up @@ -103,10 +104,18 @@ function normalizeComparablePath(value) {
}

function resolveOpenClawPackageRoot(entryPath) {
if (typeof entryPath !== "string" || !entryPath) return "";
var resolved = normalizeComparablePath(entryPath);
if (!resolved) return "";
if (pathMod.basename(resolved) === "openclaw.mjs") return pathMod.dirname(resolved);
var parent = pathMod.dirname(resolved);
if (
pathMod.basename(resolved) === "sqlite-store.worker.js" &&
pathMod.basename(parent) === "infra" &&
pathMod.basename(pathMod.dirname(parent)) === "dist"
) {
return pathMod.dirname(pathMod.dirname(parent));
}
return pathMod.basename(parent) === "dist" ? pathMod.dirname(parent) : "";
}

Expand All @@ -132,6 +141,17 @@ function isCanonicalPathWithin(rootPath, candidatePath, runtime) {
return relative !== "" && !relative.startsWith("..") && !pathMod.isAbsolute(relative);
}

function isOpenClawSqliteStagingParent(value, runtime) {
var localAppData =
runtime && runtime.localAppData ? runtime.localAppData : TRUSTED_WORKER_ENV.LOCALAPPDATA;
if (!localAppData || typeof value !== "string" || !pathMod.isAbsolute(value)) return false;
var expectedRoot = pathMod.resolve(localAppData, "openclaw").toLowerCase();
return (
normalizeComparablePath(value) === expectedRoot &&
canonicalExistingPath(value, runtime) === expectedRoot
);
}

function isOpenClawSqliteStagingRoot(value, runtime) {
if (typeof value !== "string" || !pathMod.isAbsolute(value)) return false;
var localAppData =
Expand All @@ -143,61 +163,130 @@ function isOpenClawSqliteStagingRoot(value, runtime) {
var relative = pathMod.relative(root, candidate);
var pid = runtime && runtime.pid !== undefined ? runtime.pid : process.pid;
var expectedName = new RegExp(
"^openclaw-sqlite-readonly-" +
"^openclaw-sqlite-readonly-(?:" +
pid +
"-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
"|v2)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
"i",
);
return relative === pathMod.basename(value).toLowerCase() && expectedName.test(relative);
}

function isOpenClawInternalSqliteWorkerCommand(cmd, args, stack, runtime, options) {
if (options && options.shell) return false;
if (!Array.isArray(args) || (args.length !== 4 && args.length !== 5)) return false;
if (!Array.isArray(args)) return false;
var ownership = args.length === 2;
var session = args.length === 3 && args[2] === "session";
var reclaim = args.length === 4 && args[2] === "reclaim";
if (!ownership && !session && args.length !== 4 && args.length !== 5) return false;
if (
session &&
(!options ||
!Array.isArray(options.stdio) ||
options.stdio.join(",") !== "ignore,pipe,pipe,ipc")
) {
return false;
}
if (args.length === 5 && !isOpenClawSqliteStagingRoot(args[4], runtime)) return false;
var execPath = normalizeComparablePath(
runtime && runtime.execPath ? runtime.execPath : TRUSTED_NODE_EXEC_PATH,
);
if (!execPath || normalizeComparablePath(cmd) !== execPath) return false;
if (
args[1] !== "--openclaw-sqlite-readonly-child" ||
(args[2] !== "async" && args[2] !== "sync") ||
!pathMod.isAbsolute(String(args[3]))
!ownership &&
(args[1] !== "--openclaw-sqlite-readonly-child" ||
(!session &&
!reclaim &&
((args[2] !== "async" && args[2] !== "sync") || !pathMod.isAbsolute(String(args[3])))))
) {
return false;
}
var packageRoot = resolveOpenClawPackageRoot(
runtime && runtime.entryPath ? runtime.entryPath : TRUSTED_OPENCLAW_ENTRY_PATH,
runtime && runtime.entryPath !== undefined ? runtime.entryPath : TRUSTED_OPENCLAW_ENTRY_PATH,
);
// Worker-thread preloads can run before Node initializes process.argv.
if (!packageRoot) {
packageRoot = canonicalExistingPath(
runtime && runtime.packageRoot ? runtime.packageRoot : TRUSTED_OPENCLAW_PACKAGE_ROOT,
runtime,
);
}
if (!packageRoot) return false;
var expectedWorker = normalizeComparablePath(
pathMod.join(packageRoot, "dist", "infra", "sqlite-readonly-location.worker.js"),
ownership
? pathMod.join(packageRoot, "dist", "state", "openclaw-state-ownership.worker.js")
: pathMod.join(packageRoot, "dist", "infra", "sqlite-readonly-location.worker.js"),
);
if (normalizeComparablePath(args[0]) !== expectedWorker) return false;
var stateDir = runtime && runtime.stateDir ? runtime.stateDir : TRUSTED_OPENCLAW_STATE_DIR;
var database = args[ownership ? 1 : 3];
if (reclaim && !isOpenClawSqliteStagingParent(database, runtime)) return false;
if (
pathMod.extname(String(args[3])).toLowerCase() !== ".sqlite" ||
!isCanonicalPathWithin(stateDir, args[3], runtime)
!session &&
!reclaim &&
(!pathMod.isAbsolute(String(database)) ||
pathMod.extname(String(database)).toLowerCase() !== ".sqlite" ||
!isCanonicalPathWithin(stateDir, database, runtime))
) {
return false;
}
var normalizedStack = String(stack || "")
.replace(/\//g, pathMod.sep)
.toLowerCase();
var trustedDist = normalizeComparablePath(pathMod.join(packageRoot, "dist")) + pathMod.sep;
if (ownership) {
return normalizedStack.indexOf(trustedDist + "openclaw-state-ownership-") >= 0;
}
return (
normalizedStack.indexOf(trustedDist + "sqlite-readonly-location-") >= 0 ||
normalizedStack.indexOf(trustedDist + "sqlite-readonly-worker-") >= 0
);
}

function isOpenClawInternalSqliteSessionMessage(message, runtime) {
if (message === "close") return true;
if (!message || typeof message !== "object" || !Number.isSafeInteger(message.id)) return false;
if (!Array.isArray(message.args)) {
return Boolean(
message.transfer &&
typeof message.transfer === "object" &&
(message.transfer.type === "next" || message.transfer.type === "end"),
);
}
var args = message.args;
if (args.length < 2 || args.length > 4 || !args.every((arg) => typeof arg === "string")) {
return false;
}
var mode = args[0];
var target = args[1];
if (!pathMod.isAbsolute(target)) return false;
if (mode === "staging-create" || mode === "staging-create-legacy") {
return args.length === 2 && isOpenClawSqliteStagingParent(target, runtime);
}
if (mode === "staging-retire" || mode === "staging-reconcile") {
return args.length === 2 && isOpenClawSqliteStagingRoot(target, runtime);
}
if (mode !== "sync" && mode !== "auth-profile-rows" && mode !== "operation") return false;
if (mode !== "sync" && args.length !== 2) return false;
var stateDir = runtime && runtime.stateDir ? runtime.stateDir : TRUSTED_OPENCLAW_STATE_DIR;
if (
pathMod.extname(target).toLowerCase() !== ".sqlite" ||
!isCanonicalPathWithin(stateDir, target, runtime)
) {
return false;
}
return !args[2] || isOpenClawSqliteStagingRoot(args[2], runtime);
}

function buildInternalSqliteWorkerInvocation(args, options) {
var environment = Object.assign({}, TRUSTED_WORKER_ENV);
environment[INTERNAL_SQLITE_WORKER_MARKER] = "1";
var session = args.length === 3 && args[2] === "session";
environment[INTERNAL_SQLITE_WORKER_MARKER] =
args.length === 2 ? "ownership" : session ? "session" : "1";
var nextOptions = {
env: environment,
windowsHide: true,
};
if (session) nextOptions.stdio = ["ignore", "pipe", "pipe", "ipc"];
if (options && options.encoding !== undefined) nextOptions.encoding = options.encoding;
if (options && options.maxBuffer !== undefined) nextOptions.maxBuffer = options.maxBuffer;
if (options && options.timeout !== undefined) nextOptions.timeout = options.timeout;
Expand Down Expand Up @@ -1156,6 +1245,14 @@ function install(cp, getExternalApps) {

var _spawn = cp.spawn;
cp.spawn = function (cmd, args, opts) {
if (
S.state.sandboxActive &&
isOpenClawInternalSqliteWorkerCommand(cmd, args, new Error().stack, undefined, opts)
) {
process.stderr.write("[sandbox] spawn: OpenClaw SQLite worker -> BYPASS (internal)\n");
var invocation = buildInternalSqliteWorkerInvocation(args, opts);
return _spawn.call(this, cmd, invocation.args, invocation.options);
}
var bn = pathMod.basename(String(cmd));
var isShell = isShellExe(cmd);
var hasShellOpt = (opts && opts.shell) || (args && !Array.isArray(args) && args.shell);
Expand Down Expand Up @@ -1923,6 +2020,7 @@ module.exports = {
isSafeDiagnosticCommand: isSafeDiagnosticCommand,
isSafeDiagnosticCommandStr: isSafeDiagnosticCommandStr,
isOpenClawInternalSqliteWorkerCommand: isOpenClawInternalSqliteWorkerCommand,
isOpenClawInternalSqliteSessionMessage: isOpenClawInternalSqliteSessionMessage,
buildInternalSqliteWorkerInvocation: buildInternalSqliteWorkerInvocation,
extractLaunchedApp: extractLaunchedApp,
tryDeclareAccess: tryDeclareAccess,
Expand Down
Loading
Loading