Sandbox: validate research-script imports before exec

This commit is contained in:
Rafal 2026-03-11 11:07:56 +01:00
parent dc4441322f
commit 70d729c72b
5 changed files with 774 additions and 28 deletions

View File

@ -220,6 +220,26 @@ Common pitfalls:
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesnt bring it back.
## Sandboxed research-script preflight
When a sandboxed `exec` command directly runs a Python or Node script
(`python*.py`, `node *.js`, `node *.mjs`, `node *.cjs`), OpenClaw applies a
deterministic research template before launch.
- Python sandbox template: only a curated stdlib import set plus workspace-local
modules/packages are allowed.
- Node sandbox template: only a curated `node:` builtin set plus relative
workspace-local files are allowed.
- Unsafe runtime/process/network imports such as `subprocess`, `socket`,
`node:child_process`, `node:http`, and dynamic `require()` / `import()` calls
are blocked with an `exec preflight` error before the command starts.
- Bare third-party dependencies are also blocked unless the template allowlist
is expanded in code.
This import-template validation is sandbox-only. Host exec keeps the existing
shell-bleed preflight checks, but it does not enforce the research import
template.
`tools.elevated` is an explicit escape hatch that runs `exec` on the host.
`/exec` directives only apply for authorized senders and persist per session; to hard-disable
`exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)).

View File

@ -9,6 +9,62 @@ const isWin = process.platform === "win32";
const describeNonWin = isWin ? describe.skip : describe;
describeNonWin("exec script preflight", () => {
it("blocks sandboxed node scripts that import denied runtime modules", async () => {
await withTempDir("openclaw-exec-preflight-sandbox-", async (tmp) => {
const jsPath = path.join(tmp, "bad.js");
await fs.writeFile(
jsPath,
[
'const { execSync } = require("node:child_process");',
"console.log(typeof execSync);",
].join("\n"),
"utf-8",
);
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
sandbox: {
containerName: "openclaw-test-sandbox",
workspaceDir: tmp,
containerWorkdir: "/workspace",
},
});
await expect(
tool.execute("call-sandbox-blocked", {
command: "node bad.js",
workdir: tmp,
}),
).rejects.toThrow(/sandbox template "node-research" blocks import "node:child_process"/);
});
});
it("keeps import-template validation sandbox-only for host exec", async () => {
await withTempDir("openclaw-exec-preflight-host-", async (tmp) => {
const jsPath = path.join(tmp, "host-ok.js");
await fs.writeFile(
jsPath,
[
'const { execSync } = require("node:child_process");',
"console.log(typeof execSync);",
].join("\n"),
"utf-8",
);
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
const result = await tool.execute("call-host-allowed", {
command: "node host-ok.js",
workdir: tmp,
});
const text = result.content.find((block) => block.type === "text")?.text ?? "";
expect(text).toContain("function");
expect(text).not.toContain('sandbox template "node-research"');
});
});
it("blocks shell env var injection tokens in python scripts before execution", async () => {
await withTempDir("openclaw-exec-preflight-", async (tmp) => {
const pyPath = path.join(tmp, "bad.py");

View File

@ -43,6 +43,10 @@ import {
resolveWorkdir,
truncateMiddle,
} from "./bash-tools.shared.js";
import {
extractSandboxExecutionTargetFromCommand,
validateSandboxExecutionTemplateImports,
} from "./sandbox-execution-templates.js";
import { assertSandboxPath } from "./sandbox-paths.js";
export type { BashSandboxConfig } from "./bash-tools.shared.js";
@ -52,36 +56,12 @@ export type {
ExecToolDetails,
} from "./bash-tools.exec-types.js";
function extractScriptTargetFromCommand(
command: string,
): { kind: "python"; relOrAbsPath: string } | { kind: "node"; relOrAbsPath: string } | null {
const raw = command.trim();
if (!raw) {
return null;
}
// Intentionally simple parsing: we only support common forms like
// python file.py
// python3 -u file.py
// node --experimental-something file.js
// If the command is more complex (pipes, heredocs, quoted paths with spaces), skip preflight.
const pythonMatch = raw.match(/^\s*(python3?|python)\s+(?:-[^\s]+\s+)*([^\s]+\.py)\b/i);
if (pythonMatch?.[2]) {
return { kind: "python", relOrAbsPath: pythonMatch[2] };
}
const nodeMatch = raw.match(/^\s*(node)\s+(?:--[^\s]+\s+)*([^\s]+\.js)\b/i);
if (nodeMatch?.[2]) {
return { kind: "node", relOrAbsPath: nodeMatch[2] };
}
return null;
}
async function validateScriptFileForShellBleed(params: {
async function validateScriptFilePreflight(params: {
command: string;
workdir: string;
sandboxed: boolean;
}): Promise<void> {
const target = extractScriptTargetFromCommand(params.command);
const target = extractSandboxExecutionTargetFromCommand(params.command);
if (!target) {
return;
}
@ -146,6 +126,17 @@ async function validateScriptFileForShellBleed(params: {
);
}
}
if (!params.sandboxed) {
return;
}
validateSandboxExecutionTemplateImports({
kind: target.kind,
filePath: absPath,
workdir: params.workdir,
content,
});
}
export function createExecTool(
@ -467,7 +458,11 @@ export function createExecTool(
// Preflight: catch a common model failure mode (shell syntax leaking into Python/JS sources)
// before we execute and burn tokens in cron loops.
await validateScriptFileForShellBleed({ command: params.command, workdir });
await validateScriptFilePreflight({
command: params.command,
workdir,
sandboxed: Boolean(sandbox),
});
const run = await runExecProcess({
command: params.command,

View File

@ -0,0 +1,143 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-utils/temp-dir.js";
import {
extractSandboxExecutionTargetFromCommand,
validateSandboxExecutionTemplateImports,
} from "./sandbox-execution-templates.js";
describe("sandbox execution templates", () => {
it("extracts direct python and node script targets from simple exec commands", () => {
expect(extractSandboxExecutionTargetFromCommand("python3 -u scripts/run.py")).toEqual({
kind: "python",
templateId: "python-research",
relOrAbsPath: "scripts/run.py",
});
expect(extractSandboxExecutionTargetFromCommand("node --trace-warnings tools/run.mjs")).toEqual(
{
kind: "node",
templateId: "node-research",
relOrAbsPath: "tools/run.mjs",
},
);
expect(extractSandboxExecutionTargetFromCommand('node "quoted.js"')).toBeNull();
});
it("allows python stdlib imports and workspace-local helper modules", async () => {
await withTempDir("openclaw-sandbox-template-python-", async (tmp) => {
const mainPath = path.join(tmp, "research.py");
await fs.writeFile(path.join(tmp, "helpers.py"), "VALUE = 1\n", "utf-8");
await fs.writeFile(
mainPath,
["import math", "import helpers", "from collections import Counter", "print(math.pi)"].join(
"\n",
),
"utf-8",
);
const content = await fs.readFile(mainPath, "utf-8");
expect(() =>
validateSandboxExecutionTemplateImports({
kind: "python",
filePath: mainPath,
workdir: tmp,
content,
}),
).not.toThrow();
});
});
it("blocks unsafe python process imports with an actionable sandbox-template error", async () => {
await withTempDir("openclaw-sandbox-template-python-", async (tmp) => {
const mainPath = path.join(tmp, "research.py");
await fs.writeFile(mainPath, ["import json", "import subprocess"].join("\n"), "utf-8");
const content = await fs.readFile(mainPath, "utf-8");
expect(() =>
validateSandboxExecutionTemplateImports({
kind: "python",
filePath: mainPath,
workdir: tmp,
content,
}),
).toThrow(/sandbox template "python-research" blocks import "subprocess"/);
});
});
it("blocks non-allowlisted python third-party dependencies", async () => {
await withTempDir("openclaw-sandbox-template-python-", async (tmp) => {
const mainPath = path.join(tmp, "research.py");
await fs.writeFile(mainPath, "import pandas\n", "utf-8");
const content = await fs.readFile(mainPath, "utf-8");
expect(() =>
validateSandboxExecutionTemplateImports({
kind: "python",
filePath: mainPath,
workdir: tmp,
content,
}),
).toThrow(/Only deterministic stdlib imports and workspace-local modules are allowed/);
});
});
it("allows node builtins on the template allowlist and relative helper imports", async () => {
await withTempDir("openclaw-sandbox-template-node-", async (tmp) => {
const mainPath = path.join(tmp, "research.mjs");
await fs.writeFile(path.join(tmp, "helpers.mjs"), "export const value = 1;\n", "utf-8");
await fs.writeFile(
mainPath,
[
'import path from "node:path";',
'import { value } from "./helpers.mjs";',
"console.log(path.basename(import.meta.url), value);",
].join("\n"),
"utf-8",
);
const content = await fs.readFile(mainPath, "utf-8");
expect(() =>
validateSandboxExecutionTemplateImports({
kind: "node",
filePath: mainPath,
workdir: tmp,
content,
}),
).not.toThrow();
});
});
it("blocks unsafe node runtime dependencies and dynamic require calls", async () => {
await withTempDir("openclaw-sandbox-template-node-", async (tmp) => {
const blockedPath = path.join(tmp, "blocked.mjs");
await fs.writeFile(
blockedPath,
'import { execSync } from "node:child_process";\nconsole.log(execSync);\n',
"utf-8",
);
const blockedContent = await fs.readFile(blockedPath, "utf-8");
expect(() =>
validateSandboxExecutionTemplateImports({
kind: "node",
filePath: blockedPath,
workdir: tmp,
content: blockedContent,
}),
).toThrow(/sandbox template "node-research" blocks import "node:child_process"/);
const dynamicPath = path.join(tmp, "dynamic.cjs");
await fs.writeFile(dynamicPath, "const name = process.argv[2];\nrequire(name);\n", "utf-8");
const dynamicContent = await fs.readFile(dynamicPath, "utf-8");
expect(() =>
validateSandboxExecutionTemplateImports({
kind: "node",
filePath: dynamicPath,
workdir: tmp,
content: dynamicContent,
}),
).toThrow(/Dynamic require\(\) calls are not allowed/);
});
});
});

View File

@ -0,0 +1,532 @@
import fs from "node:fs";
import path from "node:path";
export type SandboxExecutionTemplateId = "python-research" | "node-research";
export type SandboxExecutionTarget = {
kind: "python" | "node";
templateId: SandboxExecutionTemplateId;
relOrAbsPath: string;
};
type SandboxExecutionTemplate = {
id: SandboxExecutionTemplateId;
allowedImports: ReadonlySet<string>;
deniedImports: ReadonlySet<string>;
allowedImportsSummary: string;
};
const PYTHON_RESEARCH_ALLOWED_IMPORTS = [
"argparse",
"bisect",
"collections",
"csv",
"dataclasses",
"datetime",
"decimal",
"functools",
"heapq",
"itertools",
"json",
"math",
"pathlib",
"random",
"re",
"statistics",
"string",
"sys",
"textwrap",
"typing",
] as const;
const PYTHON_RESEARCH_DENIED_IMPORTS = [
"asyncio",
"ctypes",
"ftplib",
"http",
"importlib",
"marshal",
"multiprocessing",
"os",
"pickle",
"runpy",
"site",
"socket",
"subprocess",
"telnetlib",
"urllib",
] as const;
const NODE_RESEARCH_ALLOWED_IMPORTS = [
"node:assert",
"node:assert/strict",
"node:buffer",
"node:events",
"node:fs",
"node:fs/promises",
"node:path",
"node:stream",
"node:stream/promises",
"node:string_decoder",
"node:timers/promises",
"node:url",
"node:util",
] as const;
const NODE_RESEARCH_DENIED_IMPORTS = [
"node:child_process",
"node:cluster",
"node:dgram",
"node:dns",
"node:dns/promises",
"node:http",
"node:https",
"node:inspector",
"node:module",
"node:net",
"node:tls",
"node:vm",
"node:worker_threads",
] as const;
const NODE_BUILTIN_ALIASES = new Map<string, string>([
...NODE_RESEARCH_ALLOWED_IMPORTS.map((name) => [name.slice("node:".length), name] as const),
...NODE_RESEARCH_DENIED_IMPORTS.map((name) => [name.slice("node:".length), name] as const),
]);
function createTemplate(
id: SandboxExecutionTemplateId,
allowedImports: readonly string[],
deniedImports: readonly string[],
): SandboxExecutionTemplate {
const allowed = new Set(allowedImports);
return {
id,
allowedImports: allowed,
deniedImports: new Set(deniedImports),
allowedImportsSummary: Array.from(allowed).toSorted().join(", "),
};
}
const PYTHON_RESEARCH_TEMPLATE = createTemplate(
"python-research",
PYTHON_RESEARCH_ALLOWED_IMPORTS,
PYTHON_RESEARCH_DENIED_IMPORTS,
);
const NODE_RESEARCH_TEMPLATE = createTemplate(
"node-research",
NODE_RESEARCH_ALLOWED_IMPORTS,
NODE_RESEARCH_DENIED_IMPORTS,
);
function resolveTemplate(kind: "python" | "node"): SandboxExecutionTemplate {
return kind === "python" ? PYTHON_RESEARCH_TEMPLATE : NODE_RESEARCH_TEMPLATE;
}
function stripQuotedCommandPrefix(raw: string): string {
return raw.trim();
}
export function extractSandboxExecutionTargetFromCommand(
command: string,
): SandboxExecutionTarget | null {
const raw = stripQuotedCommandPrefix(command);
if (!raw) {
return null;
}
const pythonMatch = raw.match(
/^\s*(python(?:\d+(?:\.\d+)?)?)\s+(?:-[^\s]+\s+)*([^\s"'`][^\s]*\.py)\b/i,
);
if (pythonMatch?.[2]) {
return {
kind: "python",
templateId: "python-research",
relOrAbsPath: pythonMatch[2],
};
}
const nodeMatch = raw.match(
/^\s*node\s+(?:--[^\s]+(?:=\S+)?\s+|-[^\s]+\s+)*([^\s"'`][^\s]*\.(?:[cm]?js))\b/i,
);
if (nodeMatch?.[1]) {
return {
kind: "node",
templateId: "node-research",
relOrAbsPath: nodeMatch[1],
};
}
return null;
}
function isWithinDir(candidatePath: string, rootPath: string): boolean {
const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function fileExists(candidatePath: string): boolean {
try {
return fs.statSync(candidatePath).isFile();
} catch {
return false;
}
}
function directoryExists(candidatePath: string): boolean {
try {
return fs.statSync(candidatePath).isDirectory();
} catch {
return false;
}
}
function resolvePythonLocalImport(params: {
specifier: string;
filePath: string;
workdir: string;
}): boolean {
const trimmed = params.specifier.trim();
if (!trimmed) {
return false;
}
if (trimmed.startsWith(".")) {
return true;
}
const rootName = trimmed.split(".")[0]?.trim();
if (!rootName) {
return false;
}
const candidateRoots = [path.dirname(params.filePath), params.workdir];
for (const candidateRoot of candidateRoots) {
const fileCandidate = path.resolve(candidateRoot, `${rootName}.py`);
if (isWithinDir(fileCandidate, params.workdir) && fileExists(fileCandidate)) {
return true;
}
const packageDir = path.resolve(candidateRoot, rootName);
const initCandidate = path.join(packageDir, "__init__.py");
if (
isWithinDir(packageDir, params.workdir) &&
directoryExists(packageDir) &&
fileExists(initCandidate)
) {
return true;
}
}
return false;
}
function canonicalizeNodeSpecifier(specifier: string): string {
const trimmed = specifier.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed.startsWith("node:")) {
return trimmed;
}
return NODE_BUILTIN_ALIASES.get(trimmed) ?? trimmed;
}
function resolveNodeLocalImport(params: {
specifier: string;
filePath: string;
workdir: string;
}): boolean {
const trimmed = params.specifier.trim();
if (!(trimmed.startsWith("./") || trimmed.startsWith("../"))) {
return false;
}
const base = path.resolve(path.dirname(params.filePath), trimmed);
const candidates = [
base,
`${base}.js`,
`${base}.mjs`,
`${base}.cjs`,
path.join(base, "index.js"),
path.join(base, "index.mjs"),
path.join(base, "index.cjs"),
];
return candidates.some(
(candidate) => isWithinDir(candidate, params.workdir) && fileExists(candidate),
);
}
function buildImportError(params: {
template: SandboxExecutionTemplate;
filePath: string;
line: number;
specifier: string;
detail: string;
localHelp: string;
}): Error {
return new Error(
[
`exec preflight: sandbox template "${params.template.id}" blocks import "${params.specifier}" in ${path.basename(
params.filePath,
)}:${params.line}.`,
params.detail,
`Allowed imports: ${params.template.allowedImportsSummary}.`,
params.localHelp,
].join("\n"),
);
}
function assertPythonImportAllowed(params: {
template: SandboxExecutionTemplate;
specifier: string;
filePath: string;
workdir: string;
line: number;
}): void {
const rootSpecifier = params.specifier.trim().replace(/^\.+/, "").split(".")[0]?.trim();
if (!rootSpecifier) {
return;
}
if (params.template.deniedImports.has(rootSpecifier)) {
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: params.line,
specifier: rootSpecifier,
detail:
"Unsafe stdlib/process/network imports are not allowed inside the sandbox research template.",
localHelp:
"Allowed local imports: relative imports or workspace-local modules/packages that resolve inside the sandbox workdir.",
});
}
if (params.template.allowedImports.has(rootSpecifier)) {
return;
}
if (
resolvePythonLocalImport({
specifier: params.specifier,
filePath: params.filePath,
workdir: params.workdir,
})
) {
return;
}
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: params.line,
specifier: rootSpecifier,
detail:
"Only deterministic stdlib imports and workspace-local modules are allowed for sandboxed Python research scripts.",
localHelp:
"Third-party packages and non-local bare imports are denied unless the template allowlist is expanded in code.",
});
}
function validatePythonImports(params: {
template: SandboxExecutionTemplate;
filePath: string;
workdir: string;
content: string;
}): void {
const lines = params.content.split(/\r?\n/);
for (const [index, rawLine] of lines.entries()) {
const line = rawLine.replace(/#.*$/, "");
if (!line.trim()) {
continue;
}
if (/\b__import__\s*\(/.test(line)) {
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: index + 1,
specifier: "__import__",
detail: "Dynamic Python imports are not allowed inside sandbox research templates.",
localHelp:
"Use explicit import statements so the sandbox validator can prove which modules are used.",
});
}
const importMatch = line.match(/^\s*import\s+(.+)$/);
if (importMatch?.[1]) {
const specifiers = importMatch[1]
.split(",")
.map((part) =>
part
.trim()
.split(/\s+as\s+/i)[0]
?.trim(),
)
.filter((part): part is string => Boolean(part));
for (const specifier of specifiers) {
assertPythonImportAllowed({
template: params.template,
specifier,
filePath: params.filePath,
workdir: params.workdir,
line: index + 1,
});
}
continue;
}
const fromMatch = line.match(/^\s*from\s+([.\w]+)\s+import\s+/);
if (fromMatch?.[1]) {
assertPythonImportAllowed({
template: params.template,
specifier: fromMatch[1],
filePath: params.filePath,
workdir: params.workdir,
line: index + 1,
});
}
}
}
function assertNodeImportAllowed(params: {
template: SandboxExecutionTemplate;
specifier: string;
filePath: string;
workdir: string;
line: number;
}): void {
const canonical = canonicalizeNodeSpecifier(params.specifier);
if (params.template.deniedImports.has(canonical)) {
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: params.line,
specifier: canonical,
detail:
"Process, network, VM, and dynamic module-control imports are blocked in the sandbox Node research template.",
localHelp: "Allowed local imports: relative files that resolve inside the sandbox workdir.",
});
}
if (params.template.allowedImports.has(canonical)) {
return;
}
if (
resolveNodeLocalImport({
specifier: params.specifier,
filePath: params.filePath,
workdir: params.workdir,
})
) {
return;
}
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: params.line,
specifier: params.specifier,
detail:
"Only allowed `node:` builtins and relative workspace-local files are permitted for sandboxed Node research scripts.",
localHelp:
"Bare external packages are denied unless the template allowlist is expanded in code.",
});
}
function validateNodeImports(params: {
template: SandboxExecutionTemplate;
filePath: string;
workdir: string;
content: string;
}): void {
const literalPatterns = [
/\bimport\s+(?:type\s+)?(?:[^"'`]+\s+from\s+)?["']([^"']+)["']/g,
/\bexport\s+[^"'`]+\s+from\s+["']([^"']+)["']/g,
/\brequire\(\s*["']([^"']+)["']\s*\)/g,
/\bimport\(\s*["']([^"']+)["']\s*\)/g,
];
const lines = params.content.split(/\r?\n/);
for (const [index, rawLine] of lines.entries()) {
const trimmed = rawLine.trim();
if (
!trimmed ||
trimmed.startsWith("//") ||
trimmed.startsWith("/*") ||
trimmed.startsWith("*")
) {
continue;
}
let foundLiteralImport = false;
for (const pattern of literalPatterns) {
pattern.lastIndex = 0;
for (const match of trimmed.matchAll(pattern)) {
const specifier = match[1]?.trim();
if (!specifier) {
continue;
}
foundLiteralImport = true;
assertNodeImportAllowed({
template: params.template,
specifier,
filePath: params.filePath,
workdir: params.workdir,
line: index + 1,
});
}
}
if (/\brequire\(/.test(trimmed) && !/\brequire\(\s*["'][^"']+["']\s*\)/.test(trimmed)) {
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: index + 1,
specifier: "require(...)",
detail: "Dynamic require() calls are not allowed inside sandbox Node research templates.",
localHelp:
"Use explicit string-literal imports so the validator can prove which dependencies are used.",
});
}
if (/\bimport\(/.test(trimmed) && !/\bimport\(\s*["'][^"']+["']\s*\)/.test(trimmed)) {
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: index + 1,
specifier: "import(...)",
detail: "Dynamic import() calls are not allowed inside sandbox Node research templates.",
localHelp:
"Use explicit static imports or literal dynamic imports that stay inside the allowlist.",
});
}
if (!foundLiteralImport && /\bexport\s+[^"'`]+\s+from\s+/.test(trimmed)) {
throw buildImportError({
template: params.template,
filePath: params.filePath,
line: index + 1,
specifier: "export ... from",
detail: "Re-export specifiers must use explicit string literals.",
localHelp:
"Use a normal string-literal module specifier so the sandbox validator can evaluate it.",
});
}
}
}
export function validateSandboxExecutionTemplateImports(params: {
kind: "python" | "node";
filePath: string;
workdir: string;
content: string;
}): void {
const template = resolveTemplate(params.kind);
if (params.kind === "python") {
validatePythonImports({
template,
filePath: params.filePath,
workdir: params.workdir,
content: params.content,
});
return;
}
validateNodeImports({
template,
filePath: params.filePath,
workdir: params.workdir,
content: params.content,
});
}