mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 04:31:10 +00:00
fix(plugins): harden public contract guardrails
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user