Fix SSH sandbox remote directory args (#93367)

Merged via squash.

Prepared head SHA: 02e3d7eb9f
Co-authored-by: dmorn <10097445+dmorn@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
Daniel Morandini
2026-06-16 12:45:18 +02:00
committed by GitHub
parent b0a2b65d81
commit 6163425a2d
4 changed files with 85 additions and 26 deletions
+13 -13
View File
@@ -146,7 +146,7 @@ export const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
" ;;",
"esac",
].join("\n");
const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
export const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
"set -e",
'target="$1"',
'root="${2:-$1}"',
@@ -157,13 +157,14 @@ const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
'[ -n "$target" ] || target="/"',
'[ -n "$root" ] || root="/"',
'case "$target/" in "$root"/*|"$root/") ;; *) echo "remote directory must stay under root: $target" >&2; exit 1 ;; esac',
'old_ifs="$IFS"',
'IFS="/"',
"set -- ${target#/} ${root#/}",
'IFS="$old_ifs"',
"for part do",
' [ -n "$part" ] || continue',
' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac',
'for path_to_check in "$target" "$root"; do',
' relative="${path_to_check#/}"',
' while [ -n "$relative" ]; do',
' part="${relative%%/*}"',
' if [ "$part" = "$relative" ]; then relative=""; else relative="${relative#*/}"; fi',
' [ -n "$part" ] || continue',
' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac',
" done",
"done",
'if [ -L "$root" ]; then echo "unsafe remote root symlink: $root" >&2; exit 1; fi',
'mkdir -p -- "$root"',
@@ -171,10 +172,9 @@ const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
'relative="${target#"$root"}"',
'relative="${relative#/}"',
'current="$canonical_root"',
'IFS="/"',
"set -- $relative",
'IFS="$old_ifs"',
"for part do",
'while [ -n "$relative" ]; do',
' part="${relative%%/*}"',
' if [ "$part" = "$relative" ]; then relative=""; else relative="${relative#*/}"; fi',
' [ -n "$part" ] || continue',
' if [ "$current" = "/" ]; then next="/$part"; else next="$current/$part"; fi',
' if [ -L "$next" ]; then echo "unsafe remote directory symlink: $next" >&2; exit 1; fi',
@@ -678,7 +678,7 @@ class OpenShellSandboxBackendImpl {
this.params.remoteWorkspaceDir,
);
await this.runRemoteShellScriptInternal({
script: `${ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
script: `${ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
args: [remoteSkillsWorkspaceDir, this.params.remoteWorkspaceDir],
});
const stats = await fs.lstat(this.params.createParams.skillsWorkspaceDir).catch(() => null);
@@ -29,6 +29,7 @@ const cliMocks = vi.hoisted(() => ({
let createOpenShellSandboxBackendManager: typeof import("./backend.js").createOpenShellSandboxBackendManager;
let createOpenShellSandboxBackendFactory: typeof import("./backend.js").createOpenShellSandboxBackendFactory;
let ensureOpenShellRemoteRealDirectoryScript: typeof import("./backend.js").ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT;
describe("openshell cli helpers", () => {
const originalEnv = { ...process.env };
@@ -170,8 +171,11 @@ describe("openshell backend manager", () => {
runOpenShellCli: cliMocks.runOpenShellCli,
};
});
({ createOpenShellSandboxBackendFactory, createOpenShellSandboxBackendManager } =
await import("./backend.js"));
({
ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT: ensureOpenShellRemoteRealDirectoryScript,
createOpenShellSandboxBackendFactory,
createOpenShellSandboxBackendManager,
} = await import("./backend.js"));
});
afterAll(() => {
@@ -183,6 +187,36 @@ describe("openshell backend manager", () => {
vi.clearAllMocks();
});
it.runIf(process.platform !== "win32")(
"preserves caller positional args after OpenShell remote directory validation",
async () => {
const realParent = await makeTempDir("openclaw-openshell-real-");
const root = path.join(realParent, "sandbox");
const target = path.join(root, ".openclaw", "sandbox-skills");
const result = spawnSync(
"/bin/sh",
[
"-c",
[
ensureOpenShellRemoteRealDirectoryScript,
'printf "%s\\n%s\\n" "$1" "$2"',
'touch "$1/proof"',
'find "$1" -mindepth 1 -maxdepth 1 -name proof -print',
].join("\n"),
"openclaw-openshell-dir",
target,
root,
],
{ encoding: "utf8" },
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim().split("\n")).toEqual([target, root, path.join(target, "proof")]);
},
);
it("checks runtime status with config override from OpenClaw config", async () => {
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
+25
View File
@@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { makeTempDir } from "../../../test/helpers/temp-dir.js";
import {
buildExecRemoteCommand,
buildValidatedExecRemoteCommand,
@@ -212,6 +213,30 @@ describe("sandbox ssh helpers", () => {
},
);
it.runIf(process.platform !== "win32")(
"preserves caller positional args for commands after remote directory validation",
async () => {
const realParent = makeTempDir(tempDirs, "openclaw-ssh-real-");
const root = path.join(realParent, "runtime");
const target = path.join(root, "workspace", ".openclaw", "sandbox-skills");
const { stdout } = await execFileAsync("/bin/sh", [
"-c",
[
ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT,
'printf "%s\\n%s\\n" "$1" "$2"',
'touch "$1/proof"',
'find "$1" -mindepth 1 -maxdepth 1 -name proof -print',
].join("\n"),
"openclaw-remote-dir",
target,
root,
]);
expect(stdout.trim().split("\n")).toEqual([target, root, path.join(target, "proof")]);
},
);
it.runIf(process.platform !== "win32")(
"rejects symlinked directories inside the trusted remote root",
async () => {
+11 -11
View File
@@ -666,13 +666,14 @@ export const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
'[ -n "$target" ] || target="/"',
'[ -n "$root" ] || root="/"',
'case "$target/" in "$root"/*|"$root/") ;; *) echo "remote directory must stay under root: $target" >&2; exit 1 ;; esac',
'old_ifs="$IFS"',
'IFS="/"',
"set -- ${target#/} ${root#/}",
'IFS="$old_ifs"',
"for part do",
' [ -n "$part" ] || continue',
' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac',
'for path_to_check in "$target" "$root"; do',
' relative="${path_to_check#/}"',
' while [ -n "$relative" ]; do',
' part="${relative%%/*}"',
' if [ "$part" = "$relative" ]; then relative=""; else relative="${relative#*/}"; fi',
' [ -n "$part" ] || continue',
' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac',
" done",
"done",
'if [ -L "$root" ]; then echo "unsafe remote root symlink: $root" >&2; exit 1; fi',
'mkdir -p -- "$root"',
@@ -680,10 +681,9 @@ export const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
'relative="${target#"$root"}"',
'relative="${relative#/}"',
'current="$canonical_root"',
'IFS="/"',
"set -- $relative",
'IFS="$old_ifs"',
"for part do",
'while [ -n "$relative" ]; do',
' part="${relative%%/*}"',
' if [ "$part" = "$relative" ]; then relative=""; else relative="${relative#*/}"; fi',
' [ -n "$part" ] || continue',
' if [ "$current" = "/" ]; then next="/$part"; else next="$current/$part"; fi',
' if [ -L "$next" ]; then echo "unsafe remote directory symlink: $next" >&2; exit 1; fi',