Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Reject plugin-controlled entrypoint, source-root, and target-checkout path escapes before filesystem access; preserve explicit operator paths and fixture-suite configuration, and explain how to migrate rejected sibling checkout settings. Thanks @SebTardif.

- Initialize pnpm/Yarn workflows without querying a package manager before Corepack setup, and install Bun without requiring an npm lockfile in Bun workflows.

- Capture in-process CPU, memory, event-loop, and active-resource snapshots at workload phase boundaries, preserving signed deltas and process/thread attribution separately from sampled child-process estimates.
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,21 @@ Use `plugin-inspector.config.json` for a standalone config file:
},
"capture": {
"mockSdk": true
},
"openclaw": {
"defaultCheckoutPath": "../openclaw"
}
}
```

Plugin-owned entrypoints, `sourceRoot`, and `openclaw.defaultCheckoutPath` must
stay inside the plugin root. Absolute paths, UNC shares, and paths that escape
the root are rejected before filesystem access. An invalid `sourceRoot` stops
inspection; an invalid checkout setting produces `target-openclaw-rejected`.
Compare against a sibling OpenClaw checkout with `--openclaw ../openclaw` or
the API's `openclawPath` option. Operator-owned fixture-suite configurations
retain their existing sibling checkout and source paths.

These are lexical path checks. They do not follow or reject symlinks or Windows
junctions, and do not sandbox runtime capture.

Inspect the resolved config before wiring CI:

```bash
Expand Down
3 changes: 0 additions & 3 deletions examples/plugin-inspector.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,5 @@
},
"capture": {
"mockSdk": true
},
"openclaw": {
"defaultCheckoutPath": "../openclaw"
}
}
18 changes: 17 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { resolveJailedPluginPath } from "./path-utils.js";

export const npmPackagePayloadDir = ".crabpot-package";
export const defaultPluginRootConfigFiles = ["plugin-inspector.config.json", ".plugin-inspector.json"];
export const packageJsonConfigKeys = ["pluginInspector", "plugin-inspector"];
const pluginRootConfig = Symbol("pluginRootConfig");

export function isPluginRootConfig(config) {
return config[pluginRootConfig] === true;
}

export async function loadInspectorConfig(configPath, options = {}) {
if (!configPath) {
Expand Down Expand Up @@ -123,7 +129,16 @@ export function fixtureCheckoutPath(config, fixture) {
export function fixtureSourceRoot(config, fixture) {
const checkoutPath = fixtureCheckoutPath(config, fixture);
if (fixture.subdir) {
return path.join(checkoutPath, fixture.subdir);
if (!isPluginRootConfig(config)) {
return path.join(checkoutPath, fixture.subdir);
}
const jailed = resolveJailedPluginPath(checkoutPath, fixture.subdir);
if (!jailed) {
throw new Error(
`sourceRoot ${JSON.stringify(fixture.subdir)} is outside the plugin root; refuse to scan a replacement tree`,
);
}
return jailed;
}
if (fixture.package) {
return path.join(checkoutPath, npmPackagePayloadDir);
Expand Down Expand Up @@ -153,6 +168,7 @@ export async function normalizePluginRootConfig(config, options = {}) {
}

return {
[pluginRootConfig]: true,
version: 1,
submoduleRoot: ".",
capture: config.capture,
Expand Down
14 changes: 12 additions & 2 deletions src/fixture-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
import path from "node:path";
import { compatRecordForIssueCode } from "./contract-probes.js";
import { readJsonFile } from "./json-file.js";
import { resolveJailedPluginPath } from "./path-utils.js";
import { satisfiesOpenClawCompatibilityRange } from "./openclaw-version.js";

const conversationAccessHooks = new Set(["agent_end", "llm_input", "llm_output"]);
Expand Down Expand Up @@ -1088,7 +1089,15 @@ function collectOpenClawEntrypoints(packageDir, openclaw, options) {
];

return entrypoints.map((entrypoint) => {
const resolvedPath = path.resolve(packageDir, entrypoint.specifier);
const resolvedPath = resolveJailedPluginPath(packageDir, entrypoint.specifier);
if (!resolvedPath) {
return {
...entrypoint,
relativePath: entrypoint.specifier,
exists: false,
requiresBuild: /(^|\/)dist\//.test(entrypoint.specifier) || /(^|\/)build\//.test(entrypoint.specifier),
};
}
const relativePath = path.relative(options.rootDir, resolvedPath);
return {
...entrypoint,
Expand Down Expand Up @@ -1121,7 +1130,8 @@ function hasUsablePackageRuntimeEntrypoint(entrypoint, packageSummary, entrypoin
}

const packageDir = path.dirname(packageSummary.path);
return existsSync(path.resolve(packageDir, runtimeBuildSpecifier));
const resolvedRuntime = resolveJailedPluginPath(packageDir, runtimeBuildSpecifier);
return Boolean(resolvedRuntime && existsSync(resolvedRuntime));
}

function isSourceEntrypoint(specifier) {
Expand Down
12 changes: 8 additions & 4 deletions src/inspector.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createCaptureApi } from "./capture-api.js";
import { captureApiOptionsForPlugin } from "./capture-config.js";
import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js";
import { fixtureCheckoutPath, fixtureSourceRoot, isPluginRootConfig } from "./config.js";
import { buildCompatibilityFixtureReport } from "./fixture-summary.js";
import { readOpenClawTargetSurface } from "./openclaw-target.js";
import { prepareOpenClawTarget, resolveOpenClawTargetVersion } from "./openclaw-version.js";
import { isWithinPluginRoot, resolveJailedPluginPath } from "./path-utils.js";
import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js";
import { buildCompatibilityReport, buildReport } from "./report.js";
import { inspectSdkDeprecations } from "./sdk-deprecation-rules.js";
Expand Down Expand Up @@ -39,7 +40,7 @@ export async function inspectCompatibilityFixtureSet(config, options = {}) {
(options.openclawVersion
? await prepareOpenClawTarget(await resolveOpenClawTargetVersion(options.openclawVersion, options), options)
: await readOpenClawTargetSurface({
configuredPath: options.openclawPath,
configuredPath: options.openclawPath ?? (isPluginRootConfig(config) ? undefined : config.openclaw?.defaultCheckoutPath),
manifest: config,
rootDir: config.rootDir,
}));
Expand Down Expand Up @@ -656,7 +657,10 @@ function collectEntrypoint(entrypoints, entrypointFiles, packageDir, value) {
}

function entrypointCandidates(packageDir, specifier) {
const resolved = path.resolve(packageDir, specifier);
const resolved = resolveJailedPluginPath(packageDir, specifier);
if (!resolved) {
return [];
}
if (path.extname(resolved)) {
return [resolved];
}
Expand All @@ -670,7 +674,7 @@ function entrypointCandidates(packageDir, specifier) {
path.join(resolved, "index.mjs"),
path.join(resolved, "index.cjs"),
path.join(resolved, "index.ts"),
];
].filter((candidate) => isWithinPluginRoot(packageDir, candidate));
}

function uniquePaths(paths) {
Expand Down
38 changes: 34 additions & 4 deletions src/openclaw-target.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { resolveJailedPluginPath } from "./path-utils.js";

export const defaultOpenClawCheckoutPaths = ["./openclaw", "../openclaw"];

Expand All @@ -12,7 +13,16 @@ export async function readOpenClawTargetSurface(options = {}) {
return emptyTargetSurface({ configuredPath: null, status: "disabled" });
}

const requestedPaths = openClawTargetPathCandidates(options.manifest, configuredPath);
const requestedPaths = openClawTargetPathCandidates(options.manifest, configuredPath, { rootDir });
const rejectedCheckoutPath = rejectedPluginCheckoutPath(options.manifest, configuredPath, { rootDir });
if (rejectedCheckoutPath) {
return emptyTargetSurface({
configuredPath: rejectedCheckoutPath,
searchedPaths: [rejectedCheckoutPath],
status: "rejected",
message: rejectedPluginCheckoutMessage(rejectedCheckoutPath),
});
}
if (requestedPaths.length === 0) {
return emptyTargetSurface({ configuredPath: null, status: "not-configured" });
}
Expand Down Expand Up @@ -148,11 +158,26 @@ export async function readOpenClawTargetSurface(options = {}) {
};
}

export function openClawTargetPathCandidates(manifest, configuredPath) {
export function openClawTargetPathCandidates(manifest, configuredPath, options = {}) {
if (typeof configuredPath === "string") {
return [configuredPath];
}
return unique([manifest?.openclaw?.defaultCheckoutPath, ...defaultOpenClawCheckoutPaths].filter(Boolean));
const pluginPath = manifest?.openclaw?.defaultCheckoutPath;
if (rejectedPluginCheckoutPath(manifest, configuredPath, options)) {
return [];
}
return unique([pluginPath, ...defaultOpenClawCheckoutPaths].filter(Boolean));
}

function rejectedPluginCheckoutPath(manifest, configuredPath, options = {}) {
if (typeof configuredPath === "string" || configuredPath === false) {
return null;
}
const pluginPath = manifest?.openclaw?.defaultCheckoutPath;
if (typeof pluginPath !== "string" || !options.rootDir) {
return null;
}
return resolveJailedPluginPath(options.rootDir, pluginPath) ? null : pluginPath;
}

function importsCompatRecords(source) {
Expand Down Expand Up @@ -517,12 +542,17 @@ function parseStringUnion(source, typeName) {
return match ? unique([...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1])).sort() : [];
}

function emptyTargetSurface({ configuredPath, searchedPaths = undefined, status }) {
function rejectedPluginCheckoutMessage(configuredPath) {
return `plugin defaultCheckoutPath ${JSON.stringify(configuredPath)} is outside the plugin root; pass --openclaw / openclawPath to compare against a sibling checkout`;
}

function emptyTargetSurface({ configuredPath, searchedPaths = undefined, status, message }) {
return {
configuredPath,
checkoutPath: null,
searchedPaths,
status,
message,
compatRecords: [],
compatRecordStatuses: {},
compatRecordTests: {},
Expand Down
35 changes: 35 additions & 0 deletions src/path-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ export function resolveFromRoot(rootDir, value) {
return path.isAbsolute(value) ? value : path.join(rootDir, value);
}

// Lexical string check only. Does not follow or reject symlinks or Windows junctions.
export function isWithinPluginRoot(rootDir, candidatePath, pathApi = path) {
const root = pathApi.resolve(rootDir);
const candidate = pathApi.resolve(candidatePath);
const relative = pathApi.relative(root, candidate);
return relative === "" || (!isParentDirectoryRelative(relative) && !pathApi.isAbsolute(relative));
}

export function isParentDirectoryRelative(relative) {
if (typeof relative !== "string" || relative.length === 0) {
return false;
}
return (
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
relative.startsWith("../") ||
relative.startsWith("..\\")
);
}

export function resolveJailedPluginPath(rootDir, specifier, pathApi = path) {
if (typeof specifier !== "string" || specifier.length === 0) {
return null;
}
if (path.win32.isAbsolute(specifier) || path.posix.isAbsolute(specifier) || /^[A-Za-z]:/u.test(specifier)) {
return null;
}
const root = pathApi.resolve(rootDir);
const resolved = pathApi.resolve(root, specifier);
if (!isWithinPluginRoot(root, resolved, pathApi)) {
return null;
}
return resolved;
}

export function resolveRequiredFromRoot(rootDir, value, label) {
if (!value) {
throw new Error(`${label} path is required`);
Expand Down
17 changes: 16 additions & 1 deletion src/report.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export async function buildCompatibilityReport(options = {}) {
findings: [...warnings, ...suggestions],
suggestions,
logs,
warnings,
decisions,
});

Expand Down Expand Up @@ -216,7 +217,21 @@ function filterVisibleFindings(findings, targetOpenClaw, options) {
return findings.filter((finding) => isAuthorFacingFinding(finding, targetOpenClaw));
}

export function classifyCompatRecordCoverage({ targetOpenClaw, findings, suggestions, logs, decisions }) {
export function classifyCompatRecordCoverage({ targetOpenClaw, findings, suggestions, logs, warnings, decisions }) {
if (targetOpenClaw.status === "rejected") {
const diagnostic = {
fixture: "openclaw",
code: "target-openclaw-rejected",
level: "warning",
message:
targetOpenClaw.message ??
`plugin defaultCheckoutPath ${JSON.stringify(targetOpenClaw.configuredPath)} is outside the plugin root; pass --openclaw / openclawPath to compare against a sibling checkout`,
evidence: [targetOpenClaw.configuredPath ?? "not configured", "--openclaw", "openclawPath"],
};
logs.push(diagnostic);
warnings?.push(diagnostic);
return;
}
if (targetOpenClaw.status !== "ok") {
logs.push({
fixture: "openclaw",
Expand Down
8 changes: 8 additions & 0 deletions test/openclaw-target.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,14 @@ test("OpenClaw target parsing helpers stay deterministic", () => {
"./openclaw",
"../openclaw",
]);
assert.deepEqual(
openClawTargetPathCandidates({ openclaw: { defaultCheckoutPath: "../target" } }, undefined, {
rootDir: "/tmp/plugin-root",
}),
[],
);
assert.deepEqual(openClawTargetPathCandidates({}, "../target"), ["../target"]);
assert.deepEqual(openClawTargetPathCandidates({}, "//operator-share/openclaw"), ["//operator-share/openclaw"]);
assert.deepEqual(parsePluginSdkExports({ exports: { "./plugin-sdk": "", "./plugin-sdk/tools": "", ".": "" } }), [
"openclaw/plugin-sdk",
"openclaw/plugin-sdk/tools",
Expand Down
Loading
Loading