fix(plugins): harden public contract guardrails

This commit is contained in:
Altay
2026-04-08 22:42:06 +01:00
parent f1e851ecb1
commit 769e90c6af
6 changed files with 74 additions and 23 deletions

View File

@@ -15,7 +15,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Android/pairing: clear stale setup-code auth on new QR scans, bootstrap operator and node sessions from fresh pairing, prefer stored device tokens after bootstrap handoff, and pause pairing auto-retry while the app is backgrounded so scan-once Android pairing recovers reliably again. (#63199) Thanks @obviyus.
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
- Control UI: guard stale session-history reloads during fast session switches so the selected session and rendered transcript stay in sync. (#62975) Thanks @scoootscooob.
- Slack/media: preserve bearer auth across same-origin `files.slack.com` redirects while still stripping it on cross-origin Slack CDN hops, so `url_private_download` image attachments load again. (#62960) Thanks @vincentkoc.
- Matrix/gateway: wait for Matrix sync readiness before marking startup successful, keep Matrix background handler failures contained, and route fatal Matrix sync stops through channel-level restart handling instead of crashing the whole gateway. (#62779) Thanks @gumadeiras.
@@ -25,7 +24,6 @@ Docs: https://docs.openclaw.ai
- Slack/ACP: treat Slack ACP block replies as visible delivered output so OpenClaw stops re-sending the final fallback text after Slack already rendered the reply. (#62858) Thanks @gumadeiras.
- Android/manual connect: allow blank port input only for TLS manual gateway endpoints so standard HTTPS Tailscale hosts default to `443` without silently changing cleartext manual connects. (#63134) Thanks @Tyler-RNG.
- Matrix/doctor: migrate legacy `channels.matrix.dm.policy: "trusted"` configs back to compatible DM policies during `openclaw doctor --fix`, preserving explicit `allowFrom` boundaries as `allowlist` and defaulting empty legacy configs to `pairing`. (#62942) Thanks @lukeboyett.
<<<<<<< HEAD
- npm packaging: mirror bundled channel runtime deps, stage Nostr runtime deps, derive required root mirrors from manifests and built chunks, and test packed release tarballs without repo `node_modules` so fresh installs fail fast on missing plugin deps instead of crashing at runtime. (#63065) Thanks @scoootscooob.
- Windows/update: add heap headroom to Windows `pnpm build` steps during dev updates so update preflight builds stop failing on low default Node memory.
- Auth/profiles: persist explicit auth-profile upserts directly and skip external CLI sync for local writes so profile changes are saved without stale external credential state.
@@ -47,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Security/dependency audit: force `basic-ftp` to `5.2.1` to pick up the CRLF command-injection fix from GHSA-chqc-8p9q-pq6q.
- Security/dependency audit: bump Hono to `4.12.12` and `@hono/node-server` to `1.19.13` in production resolution paths.
- Slack/partial streaming: keep the final fallback reply path active when preview finalization fails so stale preview text cannot suppress the actual final answer. (#62859) Thanks @gumadeiras.
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
## 2026.4.8

View File

@@ -5,20 +5,32 @@ import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-hel
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
function normalizeIMessageTestHandle(raw: string): string {
const trimmed = raw.trim();
let trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
if (lowered.startsWith("imessage:")) {
return normalizeIMessageTestHandle(trimmed.slice("imessage:".length));
while (trimmed) {
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
if (lowered.startsWith("imessage:")) {
trimmed = trimmed.slice("imessage:".length).trim();
continue;
}
if (lowered.startsWith("sms:")) {
trimmed = trimmed.slice("sms:".length).trim();
continue;
}
if (lowered.startsWith("auto:")) {
trimmed = trimmed.slice("auto:".length).trim();
continue;
}
break;
}
if (lowered.startsWith("sms:")) {
return normalizeIMessageTestHandle(trimmed.slice("sms:".length));
}
if (lowered.startsWith("auto:")) {
return normalizeIMessageTestHandle(trimmed.slice("auto:".length));
if (!trimmed) {
return "";
}
if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) =>
normalizeLowercaseStringOrEmpty(match),

View File

@@ -21,4 +21,11 @@ describe("createIMessageTestPlugin", () => {
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
});
it("normalizes repeated transport prefixes without recursive stack growth", () => {
const plugin = createIMessageTestPlugin();
const prefixedHandle = `${"imessage:".repeat(5000)}+44 20 7946 0958`;
expect(plugin.messaging?.normalizeTarget?.(prefixedHandle)).toBe("+442079460958");
});
});

View File

@@ -8,26 +8,22 @@ import { loadPluginManifestRegistry } from "../manifest-registry.js";
const REPO_ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const RUNTIME_ENTRY_HELPER_RE = /(^|\/)plugin-entry\.runtime\.[cm]?[jt]s$/;
const GUARDED_CONTRACT_ARTIFACT_BASENAMES = new Set([
"channel-config-api.js",
"contract-api.js",
"secret-contract-api.js",
"security-contract-api.js",
]);
const SOURCE_MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"] as const;
const FORBIDDEN_CONTRACT_MODULE_SPECIFIER_PATTERNS = [
/^vitest$/u,
/^openclaw\/plugin-sdk\/testing$/u,
/(^|\/)test-api(?:\.[cm]?[jt]s)?$/u,
/(^|\/)__tests__(\/|$)/u,
/(^|\/)test-support(\/|$)/u,
/(^|\/)[^/]*\.test(?:[-.][^/]*)?(?:\.[cm]?[jt]s)?$/u,
/(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|harness)[^/]*(?:\.[cm]?[jt]s)?$/u,
/(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|test-support|harness)[^/]*(?:\.[cm]?[jt]s)?$/u,
] as const;
const FORBIDDEN_CONTRACT_MODULE_PATH_PATTERNS = [
/(^|\/)__tests__(\/|$)/u,
/(^|\/)test-support(\/|$)/u,
/(^|\/)test-api\.[cm]?[jt]s$/u,
/(^|\/)[^/]*\.test(?:[-.][^/]*)?\.[cm]?[jt]s$/u,
/(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|harness)[^/]*\.[cm]?[jt]s$/u,
/(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|test-support|harness)[^/]*\.[cm]?[jt]s$/u,
] as const;
function listBundledPluginRoots() {
return loadPluginManifestRegistry({})
@@ -53,6 +49,12 @@ function resolvePublicSurfaceSourcePath(
return null;
}
function isGuardedContractArtifactBasename(artifactBasename: string): boolean {
return (
artifactBasename === "channel-config-api.js" || artifactBasename.endsWith("contract-api.js")
);
}
function collectProductionContractEntryPaths(): Array<{
pluginId: string;
entryPath: string;
@@ -62,7 +64,7 @@ function collectProductionContractEntryPaths(): Array<{
const pluginRoot = resolve(REPO_ROOT, "extensions", plugin.dirName);
const entryPaths = new Set<string>();
for (const artifact of plugin.publicSurfaceArtifacts ?? []) {
if (!GUARDED_CONTRACT_ARTIFACT_BASENAMES.has(artifact)) {
if (!isGuardedContractArtifactBasename(artifact)) {
continue;
}
const sourcePath = resolvePublicSurfaceSourcePath(pluginRoot, artifact);
@@ -98,8 +100,9 @@ function analyzeSourceModule(params: { filePath: string; source: string }): {
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) {
const specifier =
ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : undefined;
const specifier = ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (specifier) {
specifiers.add(specifier);
}
@@ -286,6 +289,25 @@ describe("plugin entry guardrails", () => {
).toEqual(["./barrel.js", "./safe.js", "./setup.js"]);
});
it("guards contract-style production artifacts beyond the legacy allowlist", () => {
expect(isGuardedContractArtifactBasename("channel-config-api.js")).toBe(true);
expect(isGuardedContractArtifactBasename("contract-api.js")).toBe(true);
expect(isGuardedContractArtifactBasename("doctor-contract-api.js")).toBe(true);
expect(isGuardedContractArtifactBasename("web-search-contract-api.js")).toBe(true);
expect(isGuardedContractArtifactBasename("test-api.js")).toBe(false);
});
it("flags test-support directory hops in guarded contract graphs", () => {
expect(collectForbiddenContractSpecifiers(["./test-support/index.js"])).toEqual([
"./test-support/index.js",
]);
expect(
FORBIDDEN_CONTRACT_MODULE_PATH_PATTERNS.some((pattern) =>
pattern.test("extensions/demo/src/test-support/index.ts"),
),
).toBe(true);
});
it("detects aliased definePluginEntry imports from core", () => {
expect(
analyzeSourceModule({

View File

@@ -22,5 +22,11 @@ describe("bundled plugin public surface runtime", () => {
expect(() => normalizeBundledPluginArtifactSubpath("..\\outside.js")).toThrow(
/must stay plugin-local/,
);
expect(() => normalizeBundledPluginArtifactSubpath("C:outside.js")).toThrow(
/must stay plugin-local/,
);
expect(() => normalizeBundledPluginArtifactSubpath("src/C:outside.js")).toThrow(
/must stay plugin-local/,
);
});
});

View File

@@ -19,7 +19,12 @@ export function normalizeBundledPluginArtifactSubpath(artifactBasename: string):
}
const segments = normalized.split("/");
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
if (
segments.some(
(segment) =>
segment.length === 0 || segment === "." || segment === ".." || segment.includes(":"),
)
) {
throw new Error(`Bundled plugin artifact path must stay plugin-local: ${artifactBasename}`);
}