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
11 changes: 8 additions & 3 deletions scripts/state-dir-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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._-"
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/lib/state/openclaw-managed-extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
);
Expand Down
9 changes: 7 additions & 2 deletions src/lib/state/openclaw-managed-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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
Expand Down Expand Up @@ -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)
);
}

Expand Down
15 changes: 8 additions & 7 deletions test/security-sandbox-tar-traversal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]);

Expand Down
2 changes: 1 addition & 1 deletion test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
17 changes: 13 additions & 4 deletions test/state-dir-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading