mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 08:37:41 +00:00
refactor(plugins): extract safety and provenance helpers
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
type OpenClawPackageManifest,
|
||||
type PackageManifest,
|
||||
} from "./manifest.js";
|
||||
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
|
||||
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
|
||||
|
||||
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
|
||||
@@ -29,35 +30,10 @@ export type PluginDiscoveryResult = {
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
function isPathInside(baseDir: string, targetPath: string): boolean {
|
||||
const rel = path.relative(baseDir, targetPath);
|
||||
if (!rel) {
|
||||
return true;
|
||||
function currentUid(overrideUid?: number | null): number | null {
|
||||
if (overrideUid !== undefined) {
|
||||
return overrideUid;
|
||||
}
|
||||
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function safeRealpathSync(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeStatSync(targetPath: string): fs.Stats | null {
|
||||
try {
|
||||
return fs.statSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMode(mode: number): string {
|
||||
return (mode & 0o777).toString(8).padStart(3, "0");
|
||||
}
|
||||
|
||||
function currentUid(): number | null {
|
||||
if (process.platform === "win32") {
|
||||
return null;
|
||||
}
|
||||
@@ -67,28 +43,55 @@ function currentUid(): number | null {
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function isUnsafePluginCandidate(params: {
|
||||
export type CandidateBlockReason =
|
||||
| "source_escapes_root"
|
||||
| "path_stat_failed"
|
||||
| "path_world_writable"
|
||||
| "path_suspicious_ownership";
|
||||
|
||||
type CandidateBlockIssue = {
|
||||
reason: CandidateBlockReason;
|
||||
sourcePath: string;
|
||||
rootPath: string;
|
||||
targetPath: string;
|
||||
sourceRealPath?: string;
|
||||
rootRealPath?: string;
|
||||
modeBits?: number;
|
||||
foundUid?: number;
|
||||
expectedUid?: number;
|
||||
};
|
||||
|
||||
function checkSourceEscapesRoot(params: {
|
||||
source: string;
|
||||
rootDir: string;
|
||||
}): CandidateBlockIssue | null {
|
||||
const sourceRealPath = safeRealpathSync(params.source);
|
||||
const rootRealPath = safeRealpathSync(params.rootDir);
|
||||
if (!sourceRealPath || !rootRealPath) {
|
||||
return null;
|
||||
}
|
||||
if (isPathInside(rootRealPath, sourceRealPath)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason: "source_escapes_root",
|
||||
sourcePath: params.source,
|
||||
rootPath: params.rootDir,
|
||||
targetPath: params.source,
|
||||
sourceRealPath,
|
||||
rootRealPath,
|
||||
};
|
||||
}
|
||||
|
||||
function checkPathStatAndPermissions(params: {
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
}): boolean {
|
||||
const sourceReal = safeRealpathSync(params.source);
|
||||
const rootReal = safeRealpathSync(params.rootDir);
|
||||
if (sourceReal && rootReal && !isPathInside(rootReal, sourceReal)) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
source: params.source,
|
||||
message: `blocked plugin candidate: source escapes plugin root (${params.source} -> ${sourceReal}; root=${rootReal})`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
uid: number | null;
|
||||
}): CandidateBlockIssue | null {
|
||||
if (process.platform === "win32") {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const uid = currentUid();
|
||||
const pathsToCheck = [params.rootDir, params.source];
|
||||
const seen = new Set<string>();
|
||||
for (const targetPath of pathsToCheck) {
|
||||
@@ -99,39 +102,99 @@ function isUnsafePluginCandidate(params: {
|
||||
seen.add(normalized);
|
||||
const stat = safeStatSync(targetPath);
|
||||
if (!stat) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
source: targetPath,
|
||||
message: `blocked plugin candidate: cannot stat path (${targetPath})`,
|
||||
});
|
||||
return true;
|
||||
return {
|
||||
reason: "path_stat_failed",
|
||||
sourcePath: params.source,
|
||||
rootPath: params.rootDir,
|
||||
targetPath,
|
||||
};
|
||||
}
|
||||
const modeBits = stat.mode & 0o777;
|
||||
if ((modeBits & 0o002) !== 0) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
source: targetPath,
|
||||
message: `blocked plugin candidate: world-writable path (${targetPath}, mode=${formatMode(modeBits)})`,
|
||||
});
|
||||
return true;
|
||||
return {
|
||||
reason: "path_world_writable",
|
||||
sourcePath: params.source,
|
||||
rootPath: params.rootDir,
|
||||
targetPath,
|
||||
modeBits,
|
||||
};
|
||||
}
|
||||
if (
|
||||
params.origin !== "bundled" &&
|
||||
uid !== null &&
|
||||
params.uid !== null &&
|
||||
typeof stat.uid === "number" &&
|
||||
stat.uid !== uid &&
|
||||
stat.uid !== params.uid &&
|
||||
stat.uid !== 0
|
||||
) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
source: targetPath,
|
||||
message: `blocked plugin candidate: suspicious ownership (${targetPath}, uid=${stat.uid}, expected uid=${uid} or root)`,
|
||||
});
|
||||
return true;
|
||||
return {
|
||||
reason: "path_suspicious_ownership",
|
||||
sourcePath: params.source,
|
||||
rootPath: params.rootDir,
|
||||
targetPath,
|
||||
foundUid: stat.uid,
|
||||
expectedUid: params.uid,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return false;
|
||||
function findCandidateBlockIssue(params: {
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
ownershipUid?: number | null;
|
||||
}): CandidateBlockIssue | null {
|
||||
const escaped = checkSourceEscapesRoot({
|
||||
source: params.source,
|
||||
rootDir: params.rootDir,
|
||||
});
|
||||
if (escaped) {
|
||||
return escaped;
|
||||
}
|
||||
return checkPathStatAndPermissions({
|
||||
source: params.source,
|
||||
rootDir: params.rootDir,
|
||||
origin: params.origin,
|
||||
uid: currentUid(params.ownershipUid),
|
||||
});
|
||||
}
|
||||
|
||||
function formatCandidateBlockMessage(issue: CandidateBlockIssue): string {
|
||||
if (issue.reason === "source_escapes_root") {
|
||||
return `blocked plugin candidate: source escapes plugin root (${issue.sourcePath} -> ${issue.sourceRealPath}; root=${issue.rootRealPath})`;
|
||||
}
|
||||
if (issue.reason === "path_stat_failed") {
|
||||
return `blocked plugin candidate: cannot stat path (${issue.targetPath})`;
|
||||
}
|
||||
if (issue.reason === "path_world_writable") {
|
||||
return `blocked plugin candidate: world-writable path (${issue.targetPath}, mode=${formatPosixMode(issue.modeBits ?? 0)})`;
|
||||
}
|
||||
return `blocked plugin candidate: suspicious ownership (${issue.targetPath}, uid=${issue.foundUid}, expected uid=${issue.expectedUid} or root)`;
|
||||
}
|
||||
|
||||
function isUnsafePluginCandidate(params: {
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
ownershipUid?: number | null;
|
||||
}): boolean {
|
||||
const issue = findCandidateBlockIssue({
|
||||
source: params.source,
|
||||
rootDir: params.rootDir,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
});
|
||||
if (!issue) {
|
||||
return false;
|
||||
}
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
source: issue.targetPath,
|
||||
message: formatCandidateBlockMessage(issue),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function isExtensionFile(filePath: string): boolean {
|
||||
@@ -194,6 +257,7 @@ function addCandidate(params: {
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
manifest?: PackageManifest | null;
|
||||
packageDir?: string;
|
||||
@@ -209,6 +273,7 @@ function addCandidate(params: {
|
||||
rootDir: resolvedRoot,
|
||||
origin: params.origin,
|
||||
diagnostics: params.diagnostics,
|
||||
ownershipUid: params.ownershipUid,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
@@ -232,6 +297,7 @@ function addCandidate(params: {
|
||||
function discoverInDirectory(params: {
|
||||
dir: string;
|
||||
origin: PluginOrigin;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
candidates: PluginCandidate[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
@@ -266,6 +332,7 @@ function discoverInDirectory(params: {
|
||||
source: fullPath,
|
||||
rootDir: path.dirname(fullPath),
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
}
|
||||
@@ -291,6 +358,7 @@ function discoverInDirectory(params: {
|
||||
source: resolved,
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: fullPath,
|
||||
@@ -312,6 +380,7 @@ function discoverInDirectory(params: {
|
||||
source: indexFile,
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: fullPath,
|
||||
@@ -323,6 +392,7 @@ function discoverInDirectory(params: {
|
||||
function discoverFromPath(params: {
|
||||
rawPath: string;
|
||||
origin: PluginOrigin;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
candidates: PluginCandidate[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
@@ -356,6 +426,7 @@ function discoverFromPath(params: {
|
||||
source: resolved,
|
||||
rootDir: path.dirname(resolved),
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
return;
|
||||
@@ -380,6 +451,7 @@ function discoverFromPath(params: {
|
||||
source,
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: resolved,
|
||||
@@ -402,6 +474,7 @@ function discoverFromPath(params: {
|
||||
source: indexFile,
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: resolved,
|
||||
@@ -412,6 +485,7 @@ function discoverFromPath(params: {
|
||||
discoverInDirectory({
|
||||
dir: resolved,
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
@@ -424,6 +498,7 @@ function discoverFromPath(params: {
|
||||
export function discoverOpenClawPlugins(params: {
|
||||
workspaceDir?: string;
|
||||
extraPaths?: string[];
|
||||
ownershipUid?: number | null;
|
||||
}): PluginDiscoveryResult {
|
||||
const candidates: PluginCandidate[] = [];
|
||||
const diagnostics: PluginDiagnostic[] = [];
|
||||
@@ -442,6 +517,7 @@ export function discoverOpenClawPlugins(params: {
|
||||
discoverFromPath({
|
||||
rawPath: trimmed,
|
||||
origin: "config",
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: workspaceDir?.trim() || undefined,
|
||||
candidates,
|
||||
diagnostics,
|
||||
@@ -455,6 +531,7 @@ export function discoverOpenClawPlugins(params: {
|
||||
discoverInDirectory({
|
||||
dir,
|
||||
origin: "workspace",
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: workspaceRoot,
|
||||
candidates,
|
||||
diagnostics,
|
||||
@@ -467,6 +544,7 @@ export function discoverOpenClawPlugins(params: {
|
||||
discoverInDirectory({
|
||||
dir: globalDir,
|
||||
origin: "global",
|
||||
ownershipUid: params.ownershipUid,
|
||||
candidates,
|
||||
diagnostics,
|
||||
seen,
|
||||
@@ -477,6 +555,7 @@ export function discoverOpenClawPlugins(params: {
|
||||
discoverInDirectory({
|
||||
dir: bundledDir,
|
||||
origin: "bundled",
|
||||
ownershipUid: params.ownershipUid,
|
||||
candidates,
|
||||
diagnostics,
|
||||
seen,
|
||||
|
||||
Reference in New Issue
Block a user