diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 3e84534256..e54c868084 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -14,6 +14,16 @@ const HOST_GATEWAY_URL = "http://host.openshell.internal"; // ── Helpers ────────────────────────────────────────────────────── +/** + * Validate a sandbox name to prevent shell injection and OpenShell argument + * parsing issues (e.g., WSL truncation on hyphens). + * Allowed: lowercase alphanumeric, hyphens, underscores. Must start with a letter. + */ +function validateSandboxName(name) { + if (!name || typeof name !== "string") return false; + return /^[a-zA-Z][a-zA-Z0-9_-]{0,62}$/.test(name); +} + function step(n, total, msg) { console.log(""); console.log(` [${n}/${total}] ${msg}`); @@ -130,6 +140,12 @@ async function createSandbox(gpu) { const nameAnswer = await prompt(" Sandbox name [my-assistant]: "); const sandboxName = nameAnswer || "my-assistant"; + if (!validateSandboxName(sandboxName)) { + console.error(` Invalid sandbox name: '${sandboxName}'`); + console.error(" Names must start with a letter and contain only letters, digits, hyphens, or underscores."); + process.exit(1); + } + // Check if sandbox already exists in registry const existing = registry.getSandbox(sandboxName); if (existing) { @@ -139,7 +155,7 @@ async function createSandbox(gpu) { return sandboxName; } // Destroy old sandbox - run(`openshell sandbox delete ${sandboxName} 2>/dev/null || true`, { ignoreError: true }); + run(`openshell sandbox delete "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); registry.removeSandbox(sandboxName); } @@ -158,7 +174,7 @@ async function createSandbox(gpu) { const basePolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); const createArgs = [ `--from "${buildCtx}/Dockerfile"`, - `--name ${sandboxName}`, + `--name "${sandboxName}"`, `--policy "${basePolicyPath}"`, ]; if (gpu && gpu.nimCapable) createArgs.push("--gpu"); @@ -172,7 +188,7 @@ async function createSandbox(gpu) { run(`openshell sandbox create ${createArgs.join(" ")} -- env ${envArgs.join(" ")} nemoclaw-start 2>&1 | awk '/Sandbox allocated/{if(!seen){print;seen=1}next}1'`); // Forward dashboard port separately - run(`openshell forward start --background 18789 ${sandboxName}`, { ignoreError: true }); + run(`openshell forward start --background 18789 "${sandboxName}"`, { ignoreError: true }); // Clean up build context run(`rm -rf "${buildCtx}"`, { ignoreError: true }); @@ -478,4 +494,4 @@ async function onboard() { printDashboard(sandboxName, model, provider); } -module.exports = { onboard }; +module.exports = { onboard, validateSandboxName }; diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 575fcdee27..c997ca0a46 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -86,7 +86,7 @@ function applyPreset(sandboxName, presetName) { let rawPolicy = ""; try { rawPolicy = runCapture( - `openshell policy get --full ${sandboxName} 2>/dev/null`, + `openshell policy get --full "${sandboxName}" 2>/dev/null`, { ignoreError: true } ); } catch {} @@ -146,7 +146,7 @@ function applyPreset(sandboxName, presetName) { fs.writeFileSync(tmpFile, merged, "utf-8"); try { - run(`openshell policy set --policy "${tmpFile}" --wait ${sandboxName}`); + run(`openshell policy set --policy "${tmpFile}" --wait "${sandboxName}"`); console.log(` Applied preset: ${presetName}`); } finally { fs.unlinkSync(tmpFile); diff --git a/test/sandbox-name.test.js b/test/sandbox-name.test.js new file mode 100644 index 0000000000..066c5b1f27 --- /dev/null +++ b/test/sandbox-name.test.js @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +const { validateSandboxName } = require("../bin/lib/onboard"); + +describe("validateSandboxName", () => { + it("accepts simple names", () => { + assert.ok(validateSandboxName("my-assistant")); + assert.ok(validateSandboxName("sandbox1")); + assert.ok(validateSandboxName("test_box")); + }); + + it("accepts hyphenated names (WSL regression)", () => { + assert.ok(validateSandboxName("my-assistant")); + assert.ok(validateSandboxName("dev-sandbox-01")); + assert.ok(validateSandboxName("a-b-c-d")); + }); + + it("rejects empty or non-string input", () => { + assert.equal(validateSandboxName(""), false); + assert.equal(validateSandboxName(null), false); + assert.equal(validateSandboxName(undefined), false); + }); + + it("rejects names starting with non-letter", () => { + assert.equal(validateSandboxName("1sandbox"), false); + assert.equal(validateSandboxName("-sandbox"), false); + assert.equal(validateSandboxName("_sandbox"), false); + }); + + it("rejects names with spaces or shell metacharacters", () => { + assert.equal(validateSandboxName("my sandbox"), false); + assert.equal(validateSandboxName("sandbox;rm -rf"), false); + assert.equal(validateSandboxName("test$(whoami)"), false); + assert.equal(validateSandboxName("name`cmd`"), false); + assert.equal(validateSandboxName("a b"), false); + }); + + it("rejects names that could cause argument injection", () => { + assert.equal(validateSandboxName("--policy"), false); + assert.equal(validateSandboxName("-wait"), false); + }); + + it("rejects excessively long names", () => { + assert.equal(validateSandboxName("a".repeat(64)), false); + assert.ok(validateSandboxName("a".repeat(63))); + }); +});