Merge 70d729c72bd5c0e26eef045a2ed652b0729d2d17 into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
748ec9d834
@ -428,6 +428,26 @@ Common pitfalls:
|
||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||
globally or per-agent, sandboxing doesn’t 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)).
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -42,6 +42,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";
|
||||
@ -51,36 +55,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;
|
||||
}
|
||||
@ -145,6 +125,17 @@ async function validateScriptFileForShellBleed(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.sandboxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateSandboxExecutionTemplateImports({
|
||||
kind: target.kind,
|
||||
filePath: absPath,
|
||||
workdir: params.workdir,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
export function createExecTool(
|
||||
@ -501,7 +492,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,
|
||||
|
||||
143
src/agents/sandbox-execution-templates.test.ts
Normal file
143
src/agents/sandbox-execution-templates.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
532
src/agents/sandbox-execution-templates.ts
Normal file
532
src/agents/sandbox-execution-templates.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user