mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-16 03:31:10 +00:00
fix(exec): replace TOCTOU check-then-read with atomic pinned-fd open in script preflight [AI] (#62333)
* fix: address issue * fix: address review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * address review feedback * fix: address review-pr skill feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * docs: add changelog entry for PR merge
This commit is contained in:
committed by
GitHub
parent
a4cf0c765f
commit
b024fae9e5
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js";
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { SafeOpenError, readFileWithinRoot } from "../infra/fs-safe.js";
|
||||
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
|
||||
import {
|
||||
getShellPathFromLoginShell,
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
resolveWorkdir,
|
||||
truncateMiddle,
|
||||
} from "./bash-tools.shared.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import { EXEC_TOOL_DISPLAY_SUMMARY } from "./tool-description-presets.js";
|
||||
import { type AgentToolWithMeta, failedTextResult, textResult } from "./tools/common.js";
|
||||
|
||||
@@ -105,6 +104,44 @@ const PREFLIGHT_ENV_OPTIONS_WITH_VALUES = new Set([
|
||||
"--unset",
|
||||
]);
|
||||
|
||||
const SKIPPABLE_SCRIPT_PREFLIGHT_FS_ERROR_CODES = new Set([
|
||||
"EACCES",
|
||||
"EISDIR",
|
||||
"ELOOP",
|
||||
"EINVAL",
|
||||
"ENAMETOOLONG",
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
"EPERM",
|
||||
]);
|
||||
|
||||
function getNodeErrorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||
return undefined;
|
||||
}
|
||||
return String((error as { code?: unknown }).code);
|
||||
}
|
||||
|
||||
function shouldSkipScriptPreflightPathError(error: unknown): boolean {
|
||||
if (error instanceof SafeOpenError) {
|
||||
return true;
|
||||
}
|
||||
const errorCode = getNodeErrorCode(error);
|
||||
return !!(errorCode && SKIPPABLE_SCRIPT_PREFLIGHT_FS_ERROR_CODES.has(errorCode));
|
||||
}
|
||||
|
||||
function resolvePreflightRelativePath(params: { rootDir: string; absPath: string }): string | null {
|
||||
const root = path.resolve(params.rootDir);
|
||||
const candidate = path.resolve(params.absPath);
|
||||
const relative = path.relative(root, candidate);
|
||||
if (/^\.\.(?:[\\/]|$)/u.test(relative) || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
// Preserve literal "~" path segments under the workdir. `readFileWithinRoot`
|
||||
// expands home prefixes for relative paths, so normalize `~/...` to `./~/...`.
|
||||
return /^~(?:$|[\\/])/u.test(relative) ? `.${path.sep}${relative}` : relative;
|
||||
}
|
||||
|
||||
function isShellEnvAssignmentToken(token: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(token);
|
||||
}
|
||||
@@ -921,27 +958,36 @@ async function validateScriptFileForShellBleed(params: {
|
||||
const absPath = path.isAbsolute(relOrAbsPath)
|
||||
? path.resolve(relOrAbsPath)
|
||||
: path.resolve(params.workdir, relOrAbsPath);
|
||||
const relativePath = resolvePreflightRelativePath({
|
||||
rootDir: params.workdir,
|
||||
absPath,
|
||||
});
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Best-effort: only validate if file exists and is reasonably small.
|
||||
let stat: { isFile(): boolean; size: number };
|
||||
// Best-effort: only validate files that safely resolve within workdir and
|
||||
// are reasonably small. This keeps preflight checks on a pinned file
|
||||
// identity instead of trusting mutable pathnames across multiple ops.
|
||||
// Use non-blocking open to avoid stalls if a path is swapped to a FIFO.
|
||||
let content: string;
|
||||
try {
|
||||
await assertSandboxPath({
|
||||
filePath: absPath,
|
||||
cwd: params.workdir,
|
||||
root: params.workdir,
|
||||
const safeRead = await readFileWithinRoot({
|
||||
rootDir: params.workdir,
|
||||
relativePath,
|
||||
nonBlockingRead: true,
|
||||
allowSymlinkTargetWithinRoot: true,
|
||||
maxBytes: 512 * 1024,
|
||||
});
|
||||
stat = await fs.stat(absPath);
|
||||
} catch {
|
||||
continue;
|
||||
content = safeRead.buffer.toString("utf-8");
|
||||
} catch (error) {
|
||||
if (shouldSkipScriptPreflightPathError(error)) {
|
||||
// Preflight validation is best-effort: skip path/read failures and
|
||||
// continue to execute the command normally.
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (stat.size > 512 * 1024) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath, "utf-8");
|
||||
|
||||
// Common failure mode: shell env var syntax leaking into Python/JS.
|
||||
// We deliberately match all-caps/underscore vars to avoid false positives with `$` as a JS identifier.
|
||||
|
||||
Reference in New Issue
Block a user