mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:50:42 +00:00
[codex] Extract filesystem safety primitives (#77918)
* refactor: extract filesystem safety primitives * refactor: use fs-safe for file access helpers * refactor: reuse fs-safe for media reads * refactor: use fs-safe for image reads * refactor: reuse fs-safe in qqbot media opener * refactor: reuse fs-safe for local media checks * refactor: consume cleaner fs-safe api * refactor: align fs-safe json option names * fix: preserve fs-safe migration contracts * refactor: use fs-safe primitive subpaths * refactor: use grouped fs-safe subpaths * refactor: align fs-safe api usage * refactor: adapt private state store api * chore: refresh proof gate * refactor: follow fs-safe json api split * refactor: follow reduced fs-safe surface * build: default fs-safe python helper off * fix: preserve fs-safe plugin sdk aliases * refactor: consolidate fs-safe usage * refactor: unify fs-safe store usage * refactor: trim fs-safe temp workspace usage * refactor: hide low-level fs-safe primitives * build: use published fs-safe package * fix: preserve outbound recovery durability after rebase * chore: refresh pr checks
This commit is contained in:
committed by
GitHub
parent
61481eb34f
commit
538605ff44
@@ -2,6 +2,11 @@ import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
FsSafeError,
|
||||
resolveAbsolutePathForRead,
|
||||
root as fsRoot,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
@@ -50,6 +55,17 @@ function clampMaxBytes(input: unknown): number {
|
||||
}
|
||||
|
||||
function classifyFsError(err: unknown): DirFetchErrCode {
|
||||
if (err instanceof FsSafeError) {
|
||||
if (err.code === "not-found") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
if (err.code === "symlink") {
|
||||
return "SYMLINK_REDIRECT";
|
||||
}
|
||||
if (err.code === "invalid-path") {
|
||||
return "INVALID_PATH";
|
||||
}
|
||||
}
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return "NOT_FOUND";
|
||||
@@ -145,18 +161,18 @@ async function listTarEntries(tarBuffer: Buffer): Promise<string[]> {
|
||||
|
||||
async function listTreeEntries(root: string, maxEntries: number): Promise<string[] | "TOO_MANY"> {
|
||||
const results: string[] = [];
|
||||
async function visit(dir: string): Promise<boolean> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const rootHandle = await fsRoot(root);
|
||||
async function visit(relativeDir: string): Promise<boolean> {
|
||||
const entries = await rootHandle.list(relativeDir, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
const rel = path.relative(root, abs).replace(/\\/gu, "/");
|
||||
const rel = path.posix.join(relativeDir === "." ? "" : relativeDir, entry.name);
|
||||
results.push(rel);
|
||||
if (results.length > maxEntries) {
|
||||
return false;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
const ok = await visit(abs);
|
||||
if (entry.isDirectory) {
|
||||
const ok = await visit(rel);
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
@@ -164,7 +180,7 @@ async function listTreeEntries(root: string, maxEntries: number): Promise<string
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return (await visit(root)) ? results : "TOO_MANY";
|
||||
return (await visit(".")) ? results : "TOO_MANY";
|
||||
}
|
||||
|
||||
export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchResult> {
|
||||
@@ -186,22 +202,31 @@ export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchRe
|
||||
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = await fs.realpath(requestedPath);
|
||||
canonical = (
|
||||
await resolveAbsolutePathForRead(requestedPath, {
|
||||
symlinks: followSymlinks ? "follow" : "reject",
|
||||
})
|
||||
).canonicalPath;
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
const canonicalPath =
|
||||
err instanceof FsSafeError &&
|
||||
err.cause &&
|
||||
typeof err.cause === "object" &&
|
||||
"canonicalPath" in err.cause &&
|
||||
typeof err.cause.canonicalPath === "string"
|
||||
? err.cause.canonicalPath
|
||||
: undefined;
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: code === "NOT_FOUND" ? "directory not found" : `realpath failed: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!followSymlinks && canonical !== requestedPath) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "SYMLINK_REDIRECT",
|
||||
message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
|
||||
canonicalPath: canonical,
|
||||
message:
|
||||
code === "NOT_FOUND"
|
||||
? "directory not found"
|
||||
: code === "SYMLINK_REDIRECT"
|
||||
? "path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)"
|
||||
: `realpath failed: ${String(err)}`,
|
||||
...(canonicalPath ? { canonicalPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
FsSafeError,
|
||||
resolveAbsolutePathForRead,
|
||||
root,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { mimeFromExtension } from "../shared/mime.js";
|
||||
|
||||
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
|
||||
@@ -54,6 +59,17 @@ function clampMaxEntries(input: unknown): number {
|
||||
}
|
||||
|
||||
function classifyFsError(err: unknown): DirListErrCode {
|
||||
if (err instanceof FsSafeError) {
|
||||
if (err.code === "not-found") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
if (err.code === "symlink") {
|
||||
return "SYMLINK_REDIRECT";
|
||||
}
|
||||
if (err.code === "invalid-path") {
|
||||
return "INVALID_PATH";
|
||||
}
|
||||
}
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return "NOT_FOUND";
|
||||
@@ -86,22 +102,31 @@ export async function handleDirList(params: DirListParams): Promise<DirListResul
|
||||
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = await fs.realpath(requestedPath);
|
||||
canonical = (
|
||||
await resolveAbsolutePathForRead(requestedPath, {
|
||||
symlinks: followSymlinks ? "follow" : "reject",
|
||||
})
|
||||
).canonicalPath;
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
const canonicalPath =
|
||||
err instanceof FsSafeError &&
|
||||
err.cause &&
|
||||
typeof err.cause === "object" &&
|
||||
"canonicalPath" in err.cause &&
|
||||
typeof err.cause.canonicalPath === "string"
|
||||
? err.cause.canonicalPath
|
||||
: undefined;
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: code === "NOT_FOUND" ? "path not found" : `realpath failed: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!followSymlinks && canonical !== requestedPath) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "SYMLINK_REDIRECT",
|
||||
message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
|
||||
canonicalPath: canonical,
|
||||
message:
|
||||
code === "NOT_FOUND"
|
||||
? "path not found"
|
||||
: code === "SYMLINK_REDIRECT"
|
||||
? "path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)"
|
||||
: `realpath failed: ${String(err)}`,
|
||||
...(canonicalPath ? { canonicalPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,50 +147,39 @@ export async function handleDirList(params: DirListParams): Promise<DirListResul
|
||||
};
|
||||
}
|
||||
|
||||
let names: string[];
|
||||
let listedEntries: { name: string; isDirectory: boolean; size: number; mtimeMs: number }[];
|
||||
try {
|
||||
names = await fs.readdir(canonical, { encoding: "utf8" });
|
||||
const dirRoot = await root(canonical);
|
||||
listedEntries = await dirRoot.list(".", { withFileTypes: true });
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: `readdir failed: ${String(err)}`,
|
||||
message: `list failed: ${String(err)}`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
|
||||
// Sort by name for stable pagination
|
||||
names.sort((a, b) => a.localeCompare(b));
|
||||
listedEntries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const total = names.length;
|
||||
const page = names.slice(offset, offset + maxEntries);
|
||||
const total = listedEntries.length;
|
||||
const page = listedEntries.slice(offset, offset + maxEntries);
|
||||
const truncated = offset + maxEntries < total;
|
||||
const nextPageToken = truncated ? String(offset + maxEntries) : undefined;
|
||||
|
||||
const entries: DirListEntry[] = [];
|
||||
for (const name of page) {
|
||||
const entryPath = path.join(canonical, name);
|
||||
|
||||
let isDir = false;
|
||||
let size = 0;
|
||||
let mtime = 0;
|
||||
try {
|
||||
const s = await fs.stat(entryPath);
|
||||
isDir = s.isDirectory();
|
||||
size = isDir ? 0 : s.size;
|
||||
mtime = s.mtimeMs;
|
||||
} catch {
|
||||
// stat may fail for broken symlinks; keep zeros and treat as file
|
||||
}
|
||||
for (const entry of page) {
|
||||
const entryPath = path.join(canonical, entry.name);
|
||||
const isDir = entry.isDirectory;
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
name: entry.name,
|
||||
path: entryPath,
|
||||
size,
|
||||
mimeType: isDir ? "inode/directory" : mimeFromExtension(name),
|
||||
size: isDir ? 0 : entry.size,
|
||||
mimeType: isDir ? "inode/directory" : mimeFromExtension(entry.name),
|
||||
isDir,
|
||||
mtime,
|
||||
mtime: entry.mtimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
FsSafeError,
|
||||
resolveAbsolutePathForRead,
|
||||
root,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { EXTENSION_MIME } from "../shared/mime.js";
|
||||
|
||||
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
||||
@@ -70,6 +74,20 @@ function clampMaxBytes(input: unknown): number {
|
||||
}
|
||||
|
||||
function classifyFsError(err: unknown): FileFetchErrCode {
|
||||
if (err instanceof FsSafeError) {
|
||||
if (err.code === "not-found") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
if (err.code === "symlink") {
|
||||
return "SYMLINK_REDIRECT";
|
||||
}
|
||||
if (err.code === "invalid-path") {
|
||||
return "INVALID_PATH";
|
||||
}
|
||||
if (err.code === "not-file") {
|
||||
return "IS_DIRECTORY";
|
||||
}
|
||||
}
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return "NOT_FOUND";
|
||||
@@ -101,103 +119,102 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
|
||||
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = await fs.realpath(requestedPath);
|
||||
canonical = (
|
||||
await resolveAbsolutePathForRead(requestedPath, {
|
||||
symlinks: followSymlinks ? "follow" : "reject",
|
||||
})
|
||||
).canonicalPath;
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
const canonicalPath =
|
||||
err instanceof FsSafeError &&
|
||||
err.cause &&
|
||||
typeof err.cause === "object" &&
|
||||
"canonicalPath" in err.cause &&
|
||||
typeof err.cause.canonicalPath === "string"
|
||||
? err.cause.canonicalPath
|
||||
: undefined;
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message:
|
||||
code === "NOT_FOUND"
|
||||
? "file not found"
|
||||
: code === "SYMLINK_REDIRECT"
|
||||
? "path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)"
|
||||
: `realpath failed: ${String(err)}`,
|
||||
...(canonicalPath ? { canonicalPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let opened: Awaited<ReturnType<Awaited<ReturnType<typeof root>>["open"]>>;
|
||||
try {
|
||||
const parentRoot = await root(path.dirname(canonical));
|
||||
opened = await parentRoot.open(path.basename(canonical));
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: code === "NOT_FOUND" ? "file not found" : `realpath failed: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Refuse to follow symlinks anywhere in the path unless the operator
|
||||
// has explicitly opted in. A symlink in user-controlled territory
|
||||
// (e.g. ~/Downloads/evil → /etc) could redirect an allowed-looking
|
||||
// request to a disallowed canonical target. The error includes the
|
||||
// canonical path so the operator can either update their allowlist
|
||||
// to the canonical form or set followSymlinks=true on this node.
|
||||
if (!followSymlinks && canonical !== requestedPath) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "SYMLINK_REDIRECT",
|
||||
message: `path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)`,
|
||||
message: code === "IS_DIRECTORY" ? "path is a directory" : `open failed: ${String(err)}`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
|
||||
let stats: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stats = await fs.stat(canonical);
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return { ok: false, code, message: `stat failed: ${String(err)}`, canonicalPath: canonical };
|
||||
}
|
||||
const stats = opened.stat;
|
||||
if (stats.size > maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "FILE_TOO_LARGE",
|
||||
message: `file size ${stats.size} exceeds limit ${maxBytes}`,
|
||||
canonicalPath: opened.realPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "IS_DIRECTORY",
|
||||
message: "path is a directory",
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
if (!stats.isFile()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "READ_ERROR",
|
||||
message: "path is not a regular file",
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
if (stats.size > maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "FILE_TOO_LARGE",
|
||||
message: `file size ${stats.size} exceeds limit ${maxBytes}`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
if (preflightOnly) {
|
||||
return {
|
||||
ok: true,
|
||||
path: opened.realPath,
|
||||
size: stats.size,
|
||||
mimeType: "",
|
||||
base64: "",
|
||||
sha256: "",
|
||||
preflightOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = await opened.handle.readFile();
|
||||
if (buffer.byteLength > maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "FILE_TOO_LARGE",
|
||||
message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`,
|
||||
canonicalPath: opened.realPath,
|
||||
};
|
||||
}
|
||||
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const base64 = buffer.toString("base64");
|
||||
const mimeType = detectMimeType(opened.realPath);
|
||||
|
||||
if (preflightOnly) {
|
||||
return {
|
||||
ok: true,
|
||||
path: canonical,
|
||||
size: stats.size,
|
||||
mimeType: "",
|
||||
base64: "",
|
||||
sha256: "",
|
||||
preflightOnly: true,
|
||||
path: opened.realPath,
|
||||
size: buffer.byteLength,
|
||||
mimeType,
|
||||
base64,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await fs.readFile(canonical);
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return { ok: false, code, message: `read failed: ${String(err)}`, canonicalPath: canonical };
|
||||
}
|
||||
|
||||
if (buffer.byteLength > maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "FILE_TOO_LARGE",
|
||||
message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`,
|
||||
canonicalPath: canonical,
|
||||
code,
|
||||
message: `read failed: ${String(err)}`,
|
||||
canonicalPath: opened.realPath,
|
||||
};
|
||||
} finally {
|
||||
await opened.handle.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const base64 = buffer.toString("base64");
|
||||
const mimeType = detectMimeType(canonical);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: canonical,
|
||||
size: buffer.byteLength,
|
||||
mimeType,
|
||||
base64,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
canonicalPathFromExistingAncestor,
|
||||
FsSafeError,
|
||||
resolveAbsolutePathForWrite,
|
||||
root,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
const MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16 MB
|
||||
|
||||
@@ -39,74 +45,37 @@ function err(code: string, message: string, canonicalPath?: string): FileWriteEr
|
||||
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findExistingAncestor(p: string): Promise<string | null> {
|
||||
let current = p;
|
||||
while (true) {
|
||||
try {
|
||||
await fs.lstat(current);
|
||||
return current;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async function canonicalTargetFromExistingAncestor(targetPath: string): Promise<string> {
|
||||
const ancestor = await findExistingAncestor(targetPath);
|
||||
if (!ancestor) {
|
||||
return targetPath;
|
||||
}
|
||||
let canonicalAncestor: string;
|
||||
try {
|
||||
canonicalAncestor = await fs.realpath(ancestor);
|
||||
} catch {
|
||||
canonicalAncestor = ancestor;
|
||||
}
|
||||
const relative = path.relative(ancestor, targetPath);
|
||||
return relative ? path.join(canonicalAncestor, relative) : canonicalAncestor;
|
||||
}
|
||||
|
||||
async function rejectParentSymlinkRedirect(
|
||||
targetPath: string,
|
||||
parentDir: string,
|
||||
): Promise<FileWriteError | null> {
|
||||
const ancestor = await findExistingAncestor(parentDir);
|
||||
if (!ancestor) {
|
||||
return null;
|
||||
}
|
||||
let canonicalAncestor: string;
|
||||
try {
|
||||
canonicalAncestor = await fs.realpath(ancestor);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (canonicalAncestor === ancestor) {
|
||||
return null;
|
||||
}
|
||||
const canonicalTarget = path.join(canonicalAncestor, path.relative(ancestor, targetPath));
|
||||
function symlinkRedirectError(error: FsSafeError): FileWriteError {
|
||||
const canonicalTarget =
|
||||
error.cause &&
|
||||
typeof error.cause === "object" &&
|
||||
"canonicalPath" in error.cause &&
|
||||
typeof error.cause.canonicalPath === "string"
|
||||
? error.cause.canonicalPath
|
||||
: undefined;
|
||||
return err(
|
||||
"SYMLINK_REDIRECT",
|
||||
`parent ${ancestor} resolves through a symlink to ${canonicalAncestor}; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)`,
|
||||
"path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)",
|
||||
canonicalTarget,
|
||||
);
|
||||
}
|
||||
|
||||
function writeFsSafeError(error: FsSafeError, targetPath: string): FileWriteError {
|
||||
if (error.code === "symlink") {
|
||||
return err(
|
||||
"SYMLINK_TARGET_DENIED",
|
||||
`path is a symlink; refusing to write through it: ${targetPath}`,
|
||||
);
|
||||
}
|
||||
if (error.code === "not-file") {
|
||||
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
|
||||
}
|
||||
if (error.code === "already-exists") {
|
||||
return err("EXISTS_NO_OVERWRITE", `file already exists and overwrite is false: ${targetPath}`);
|
||||
}
|
||||
return err("WRITE_ERROR", error.message, targetPath);
|
||||
}
|
||||
|
||||
export async function handleFileWrite(
|
||||
params: Partial<FileWriteParams> & Record<string, unknown>,
|
||||
): Promise<FileWriteResult> {
|
||||
@@ -158,20 +127,21 @@ export async function handleFileWrite(
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Resolve parent dir
|
||||
const targetPath = path.normalize(rawPath);
|
||||
const parentDir = path.dirname(targetPath);
|
||||
|
||||
const parentExists = await pathExists(parentDir);
|
||||
|
||||
// Refuse symlink traversal in the existing parent chain before creating
|
||||
// missing directories. Recursive mkdir follows symlinked ancestors, so this
|
||||
// has to run before mkdir can mutate the canonical target.
|
||||
if (!followSymlinks) {
|
||||
const redirect = await rejectParentSymlinkRedirect(targetPath, parentDir);
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
let targetPath: string;
|
||||
let parentDir: string;
|
||||
let parentExists: boolean;
|
||||
try {
|
||||
const resolved = await resolveAbsolutePathForWrite(rawPath, {
|
||||
symlinks: followSymlinks ? "follow" : "reject",
|
||||
});
|
||||
targetPath = resolved.path;
|
||||
parentDir = resolved.parentDir;
|
||||
parentExists = resolved.parentExists;
|
||||
} catch (error) {
|
||||
if (error instanceof FsSafeError && error.code === "symlink") {
|
||||
return symlinkRedirectError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!parentExists) {
|
||||
@@ -189,7 +159,7 @@ export async function handleFileWrite(
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
path: await canonicalTargetFromExistingAncestor(targetPath),
|
||||
path: await canonicalPathFromExistingAncestor(targetPath),
|
||||
size: buf.length,
|
||||
sha256: computedSha256,
|
||||
overwritten: false,
|
||||
@@ -203,15 +173,19 @@ export async function handleFileWrite(
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check after mkdir as a race-defense: if the parent chain changed
|
||||
// between the first check and directory creation, fail before writing bytes.
|
||||
if (!followSymlinks) {
|
||||
const redirect = await rejectParentSymlinkRedirect(targetPath, parentDir);
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
try {
|
||||
await resolveAbsolutePathForWrite(targetPath, {
|
||||
symlinks: followSymlinks ? "follow" : "reject",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof FsSafeError && error.code === "symlink") {
|
||||
return symlinkRedirectError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const targetFileName = path.basename(targetPath);
|
||||
const parentRoot = await root(parentDir);
|
||||
let overwritten = false;
|
||||
try {
|
||||
const existingLStat = await fs.lstat(targetPath);
|
||||
@@ -232,8 +206,9 @@ export async function handleFileWrite(
|
||||
}
|
||||
overwritten = true;
|
||||
} catch (statErr: unknown) {
|
||||
// ENOENT is fine — file does not exist yet
|
||||
if ((statErr as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
const statErrorCode =
|
||||
statErr instanceof FsSafeError ? statErr.code : (statErr as NodeJS.ErrnoException).code;
|
||||
if (statErrorCode !== "not-found" && statErrorCode !== "ENOENT") {
|
||||
const message = statErr instanceof Error ? statErr.message : String(statErr);
|
||||
if (message.toLowerCase().includes("permission")) {
|
||||
return err("PERMISSION_DENIED", `permission denied: ${targetPath}`);
|
||||
@@ -259,55 +234,45 @@ export async function handleFileWrite(
|
||||
if (preflightOnly) {
|
||||
return {
|
||||
ok: true,
|
||||
path: await canonicalTargetFromExistingAncestor(targetPath),
|
||||
path: await canonicalPathFromExistingAncestor(targetPath),
|
||||
size: buf.length,
|
||||
sha256: computedSha256,
|
||||
overwritten,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Atomic write: write to tmp, then rename
|
||||
const tmpSuffix = crypto.randomBytes(8).toString("hex");
|
||||
const tmpPath = `${targetPath}.${tmpSuffix}.tmp`;
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpPath, buf);
|
||||
if (overwrite) {
|
||||
await parentRoot.write(targetFileName, buf);
|
||||
} else {
|
||||
await parentRoot.create(targetFileName, buf);
|
||||
}
|
||||
} catch (writeErr) {
|
||||
if (writeErr instanceof FsSafeError) {
|
||||
return writeFsSafeError(writeErr, targetPath);
|
||||
}
|
||||
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
||||
// Clean up tmp if possible
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
|
||||
return err("PERMISSION_DENIED", `permission denied writing to: ${parentDir}`);
|
||||
}
|
||||
return err("WRITE_ERROR", `failed to write file: ${message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(tmpPath, targetPath);
|
||||
} catch (renameErr) {
|
||||
const message = renameErr instanceof Error ? renameErr.message : String(renameErr);
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
|
||||
return err("PERMISSION_DENIED", `permission denied renaming to: ${targetPath}`);
|
||||
}
|
||||
return err("WRITE_ERROR", `failed to rename tmp to target: ${message}`);
|
||||
}
|
||||
|
||||
const writtenBuf = buf;
|
||||
|
||||
// 8. Re-realpath to resolve any symlinks in the final path
|
||||
let canonicalPath = targetPath;
|
||||
try {
|
||||
canonicalPath = await fs.realpath(targetPath);
|
||||
} catch {
|
||||
// Best effort; use normalized path as fallback
|
||||
canonicalPath = targetPath;
|
||||
const opened = await parentRoot.open(targetFileName);
|
||||
canonicalPath = opened.realPath;
|
||||
await opened.handle.close().catch(() => undefined);
|
||||
} catch (openErr) {
|
||||
if (openErr instanceof FsSafeError) {
|
||||
return writeFsSafeError(openErr, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: canonicalPath,
|
||||
size: writtenBuf.length,
|
||||
size: buf.length,
|
||||
sha256: computedSha256,
|
||||
overwritten,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export type FileTransferAuditOp = "file.fetch" | "dir.list" | "dir.fetch" | "file.write";
|
||||
|
||||
@@ -86,7 +87,11 @@ export async function appendFileTransferAudit(
|
||||
timestamp: new Date().toISOString(),
|
||||
...record,
|
||||
})}\n`;
|
||||
await fs.appendFile(auditFilePath(dir), line, { mode: 0o600 });
|
||||
await appendRegularFile({
|
||||
filePath: auditFilePath(dir),
|
||||
content: line,
|
||||
rejectSymlinkParents: true,
|
||||
});
|
||||
} catch (e) {
|
||||
process.stderr.write(`[file-transfer:audit] append failed: ${String(e)}\n`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import {
|
||||
callGatewayTool,
|
||||
listNodes,
|
||||
@@ -7,7 +6,7 @@ import {
|
||||
type AnyAgentTool,
|
||||
type NodeListNode,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveMediaBufferPath } from "openclaw/plugin-sdk/media-store";
|
||||
import { readMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
||||
import { appendFileTransferAudit } from "../shared/audit.js";
|
||||
import { throwFromNodePayload } from "../shared/errors.js";
|
||||
import {
|
||||
@@ -28,14 +27,11 @@ async function readSourceBytes(input: {
|
||||
}): Promise<{ buffer: Buffer; contentBase64: string; source: "inline" | "media" }> {
|
||||
const sourceMediaId = input.sourceMediaId?.trim();
|
||||
if (sourceMediaId) {
|
||||
const mediaPath = await resolveMediaBufferPath(sourceMediaId, FILE_TRANSFER_SUBDIR);
|
||||
const stat = await fs.stat(mediaPath);
|
||||
if (stat.size > FILE_WRITE_HARD_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`sourceMediaId too large: ${stat.size} bytes; maximum is ${FILE_WRITE_HARD_MAX_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
const buffer = await fs.readFile(mediaPath);
|
||||
const { buffer } = await readMediaBuffer(
|
||||
sourceMediaId,
|
||||
FILE_TRANSFER_SUBDIR,
|
||||
FILE_WRITE_HARD_MAX_BYTES,
|
||||
);
|
||||
return { buffer, contentBase64: buffer.toString("base64"), source: "media" };
|
||||
}
|
||||
if (input.contentBase64 === undefined) {
|
||||
|
||||
Reference in New Issue
Block a user