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:
Pavan Kumar Gondhi
2026-04-09 09:46:44 +05:30
committed by GitHub
parent a4cf0c765f
commit b024fae9e5
6 changed files with 262 additions and 27 deletions

View File

@@ -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.