From d17255eeec08d5d52cd98e2b700e657f024daf75 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 26 Jul 2026 03:45:23 -0700 Subject: [PATCH] fix(state): allow locked OpenClaw peer links Signed-off-by: Apurv Kumaria --- scripts/state-dir-guard.py | 11 ++++++++--- .../state/openclaw-managed-extensions.test.ts | 12 ++++++++++++ src/lib/state/openclaw-managed-extensions.ts | 9 +++++++-- test/security-sandbox-tar-traversal.test.ts | 15 ++++++++------- test/snapshot.test.ts | 2 +- test/state-dir-guard.test.ts | 17 +++++++++++++---- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 0cb8a38b52a..d2913e61f45 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -71,7 +71,12 @@ OPENCLAW_MUTATION_MUTEX_PATH = "/run/nemoclaw/openclaw-config-mutation.lock" # Keep this exact source/target contract aligned with # src/lib/state/openclaw-managed-extensions.ts. -OPENCLAW_GLOBAL_PACKAGE_PATH = "/usr/local/lib/node_modules/openclaw" +OPENCLAW_IMAGE_PACKAGE_PATHS = frozenset( + { + "/usr/local/lib/node_modules/openclaw", + "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw", + } +) OPENCLAW_EXTENSION_PEER_LINK_SUFFIX = ("node_modules", "openclaw") SAFE_EXTENSION_ID_CHARS = frozenset( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-" @@ -555,9 +560,9 @@ def _is_allowed_openclaw_extension_peer_symlink( relative_path: str, target: str, ) -> bool: - """Recognize the one image-owned peer link that may leave the state tree.""" + """Recognize an exact image-owned peer link that may leave the state tree.""" - if target != OPENCLAW_GLOBAL_PACKAGE_PATH: + if target not in OPENCLAW_IMAGE_PACKAGE_PATHS: return False if posixpath.basename(context.config_path) != ".openclaw": return False diff --git a/src/lib/state/openclaw-managed-extensions.test.ts b/src/lib/state/openclaw-managed-extensions.test.ts index 7873b0608d1..10e3d836861 100644 --- a/src/lib/state/openclaw-managed-extensions.test.ts +++ b/src/lib/state/openclaw-managed-extensions.test.ts @@ -114,6 +114,12 @@ describe("OpenClaw managed extension symlink policy", () => { "/usr/local/lib/node_modules/openclaw", ), ).toBe(true); + expect( + isAllowedStateSymlink( + "extensions/whatsapp/node_modules/openclaw", + "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw", + ), + ).toBe(true); expect( isAllowedStateSymlink("extensions/nemoclaw/node_modules/.bin/json5", "../json5/lib/cli.js"), ).toBe(true); @@ -151,6 +157,12 @@ describe("OpenClaw managed extension symlink policy", () => { expect( isAllowedStateSymlink("workspace/openclaw", "/usr/local/lib/node_modules/openclaw"), ).toBe(false); + expect( + isAllowedStateSymlink( + "workspace/openclaw", + "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw", + ), + ).toBe(false); expect(isAllowedStateSymlink("extensions/nemoclaw/bin/json5", "../json5/lib/cli.js")).toBe( false, ); diff --git a/src/lib/state/openclaw-managed-extensions.ts b/src/lib/state/openclaw-managed-extensions.ts index 55de115a9d4..696341ac06c 100644 --- a/src/lib/state/openclaw-managed-extensions.ts +++ b/src/lib/state/openclaw-managed-extensions.ts @@ -22,7 +22,12 @@ const EXTENSION_NPM_BIN_RE = /^extensions\/[A-Za-z0-9][A-Za-z0-9._-]*\/node_modu // target; source-only matching would permit repointing it to an arbitrary file. const OPENCLAW_EXTENSION_PEER_LINK_RE = /^extensions\/[A-Za-z0-9][A-Za-z0-9._-]*\/node_modules\/openclaw$/; -const OPENCLAW_GLOBAL_PACKAGE_PATH = "/usr/local/lib/node_modules/openclaw"; +const OPENCLAW_IMAGE_PACKAGE_PATHS: ReadonlySet = new Set([ + // Legacy global install used by older sandboxes that still need to rebuild. + "/usr/local/lib/node_modules/openclaw", + // Locked runtime install used by current images; the global path points here. + "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw", +]); // Preserve extensions baked into the freshly rebuilt image instead of // replacing them with archived copies. Messaging IDs come from the reviewed @@ -63,7 +68,7 @@ function isAllowedOpenClawExtensionPeerSymlink(relPath: string, linkTarget: stri const normalizedRelPath = relPath.split(path.sep).join("/"); return ( OPENCLAW_EXTENSION_PEER_LINK_RE.test(normalizedRelPath) && - linkTarget === OPENCLAW_GLOBAL_PACKAGE_PATH + OPENCLAW_IMAGE_PACKAGE_PATHS.has(linkTarget) ); } diff --git a/test/security-sandbox-tar-traversal.test.ts b/test/security-sandbox-tar-traversal.test.ts index f8ba849b36e..ef01bdb52fd 100644 --- a/test/security-sandbox-tar-traversal.test.ts +++ b/test/security-sandbox-tar-traversal.test.ts @@ -473,23 +473,24 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", }); it.each([ - "weather", - "slack", - ])("allows the %s OpenClaw extension peer link with the exact global package target", async (extensionName) => { + ["weather", "/usr/local/lib/node_modules/openclaw"], + ["slack", "/usr/local/lib/node_modules/openclaw"], + ["whatsapp", "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw"], + ])("allows the %s OpenClaw extension peer link with an exact image package target", async (extensionName, packageTarget) => { const { safeTarExtract } = await loadSandboxState(); const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-whitelist-extract-")); try { const targetDir = path.join(workDir, "backup"); fs.mkdirSync(targetDir, { recursive: true }); - // Archive-installed plugins symlink their OpenClaw peer dependency to - // the global package. The exact target escapes both the archive and - // /sandbox/, so it requires the narrow extension peer-link exception. + // Archive-installed plugins symlink their OpenClaw peer dependency to a + // trusted image package location. The exact target escapes both the + // archive and /sandbox/, so it requires the narrow peer-link exception. const tar = buildTar([ { path: `extensions/${extensionName}/node_modules/openclaw`, type: "2", - linkTarget: "/usr/local/lib/node_modules/openclaw", + linkTarget: packageTarget, }, ]); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index b82961ae390..e8b1ece9959 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -710,11 +710,11 @@ process.exit(0); const existingDirs = ["agents", "extensions", "workspace"]; fs.mkdirSync(binDir, { recursive: true }); for (const d of existingDirs) fs.mkdirSync(path.join(openclawDir, d), { recursive: true }); - const auditLines = [ "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/.bin/qrcode-terminal\t../qrcode-terminal/bin/qrcode-terminal.js", "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", "l\t/sandbox/.openclaw/extensions/slack/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", + "l\t/sandbox/.openclaw/extensions/whatsapp/node_modules/openclaw\t/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw", "l\t/sandbox/.openclaw/extensions/weather/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", ].join("\n"); diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index fe84e9d6be3..c5e0caf2b20 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -372,11 +372,14 @@ describe("state-dir-guard", () => { expect(fs.readFileSync(outsideFile, "utf-8")).toBe("untouched\n"); }); - it("preserves the exact image-owned OpenClaw extension peer link across transitions", () => { + it.each([ + ["slack", "/usr/local/lib/node_modules/openclaw"], + ["whatsapp", "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw"], + ])("preserves the exact image-owned OpenClaw %s peer link across transitions", (extensionId, target) => { const { configDir } = fixture(".openclaw"); - const peerLink = path.join(configDir, "extensions", "slack", "node_modules", "openclaw"); + const peerLink = path.join(configDir, "extensions", extensionId, "node_modules", "openclaw"); fs.mkdirSync(path.dirname(peerLink), { recursive: true }); - fs.symlinkSync("/usr/local/lib/node_modules/openclaw", peerLink); + fs.symlinkSync(target, peerLink); const preflight = runGuard("preflight", configDir); const locked = runGuard("lock", configDir); @@ -386,7 +389,7 @@ describe("state-dir-guard", () => { expect(locked.status, locked.stderr).toBe(0); expect(unlocked.status, unlocked.stderr).toBe(0); expect(fs.lstatSync(peerLink).isSymbolicLink()).toBe(true); - expect(fs.readlinkSync(peerLink)).toBe("/usr/local/lib/node_modules/openclaw"); + expect(fs.readlinkSync(peerLink)).toBe(target); expect(locked.lines.at(-1)).toEqual( expect.objectContaining({ type: "result", @@ -399,6 +402,12 @@ describe("state-dir-guard", () => { it.each([ ["tampered target", "slack", "node_modules/openclaw", "/usr/local/lib/node_modules/other"], + [ + "tampered locked runtime target", + "whatsapp", + "node_modules/openclaw", + "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/other", + ], [ "traversal-shaped extension id", "%2e%2e",