refactor(plugins): extract safety and provenance helpers

This commit is contained in:
Peter Steinberger
2026-02-19 15:24:02 +01:00
parent 775816035e
commit 77c748304b
6 changed files with 282 additions and 152 deletions

View File

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