Skip to content
Closed
92 changes: 92 additions & 0 deletions bin/lib/deploy.js
Original file line number Diff line number Diff line change
@@ -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,
};
13 changes: 13 additions & 0 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -31,6 +33,7 @@ function isDockerRunning() {
}
}

/** Return true if the `openshell` CLI is on PATH. */
function isOpenshellInstalled() {
try {
runCapture("command -v openshell");
Expand All @@ -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 });
Expand All @@ -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");

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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)");

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand All @@ -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");

Expand Down Expand Up @@ -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";
Expand All @@ -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");
Expand Down
77 changes: 76 additions & 1 deletion bin/lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 };
}
Comment on lines +43 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Node.js child_process.spawnSyncandexecFileSync, when the command is a bare name like ssh, is the executable resolved using options.env.PATH?

💡 Result:

Yes.

For child_process.spawnSync() and child_process.execFileSync(), when you pass a bare executable name (e.g. "ssh"), Node resolves it using PATH from the environment it will pass to the child:

  • If you provide options.env, lookup uses options.env.PATH.
  • Otherwise, lookup uses process.env.PATH.
  • If you provide options.env but omit PATH, then on Unix Node falls back to a default search path (typically /usr/bin:/bin via the OS exec semantics), while on Windows it uses the parent process’s PATH. (nodejs.org)

Citations:


🏁 Script executed:

cd bin/lib && wc -l runner.js

Repository: NVIDIA/NemoClaw

Length of output: 72


🏁 Script executed:

cat -n bin/lib/runner.js | sed -n '42,95p'

Repository: NVIDIA/NemoClaw

Length of output: 2202


🏁 Script executed:

grep -n "runArgv\|runCaptureArgv" bin/lib/deploy.js | head -20

Repository: NVIDIA/NemoClaw

Length of output: 302


🏁 Script executed:

grep -n "ssh\|scp\|rsync" bin/lib/deploy.js | head -30

Repository: NVIDIA/NemoClaw

Length of output: 300


🏁 Script executed:

cat -n bin/lib/deploy.js | head -60

Repository: NVIDIA/NemoClaw

Length of output: 2394


🏁 Script executed:

fd -t f test || fd -t f "*.test.js" || fd -t f "*.spec.js" | head -20

Repository: NVIDIA/NemoClaw

Length of output: 374


🏁 Script executed:

grep -r "PATH" bin/lib/deploy.js bin/lib/runner.js

Repository: NVIDIA/NemoClaw

Length of output: 136


🏁 Script executed:

cat -n test/deploy.test.js | head -100

Repository: NVIDIA/NemoClaw

Length of output: 4213


🏁 Script executed:

grep -n "sanitizeEnv\|BLOCKED_ENV\|PATH" test/deploy.test.js test/cli.test.js

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

grep -n "BLOCKED_ENV\|sanitizeEnv" test/deploy.test.js test/cli.test.js

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

wc -l test/deploy.test.js

Repository: NVIDIA/NemoClaw

Length of output: 82


🏁 Script executed:

cat -n test/deploy.test.js | tail -80

Repository: NVIDIA/NemoClaw

Length of output: 3392


Block PATH from caller overrides.

sanitizeEnv() does not block PATH, but bin/lib/deploy.js invokes helpers with bare command names (ssh, scp, rsync). Node.js resolves these using options.env.PATH, so a caller can redirect execution to attacker-controlled binaries. Add PATH to BLOCKED_ENV_VARS and include a regression test (similar to the existing LD_PRELOAD and NODE_OPTIONS checks).

Minimal fix
 const BLOCKED_ENV_VARS = new Set([
+  "PATH",
   "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES",
   "NODE_OPTIONS", "BASH_ENV", "ENV",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const BLOCKED_ENV_VARS = new Set([
"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",
]);
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 };
}
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",
]);
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 };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/runner.js` around lines 42 - 58, The environment sanitizer currently
allows callers to override PATH, letting bare commands (ssh/scp/rsync) be
resolved to attacker-controlled binaries; update the BLOCKED_ENV_VARS Set to
include "PATH" so sanitizeEnv() will reject PATH overrides (ensure the thrown
error message still lists blocked keys), and add a regression test mirroring the
existing LD_PRELOAD/NODE_OPTIONS tests that calls sanitizeEnv (or invokes
runArgv flow that uses sanitizeEnv) with a PATH in the callerEnv and asserts an
error is thrown.


/**
* 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, {
Expand All @@ -52,4 +127,4 @@ function runCapture(cmd, opts = {}) {
}
}

module.exports = { ROOT, SCRIPTS, run, runCapture };
module.exports = { ROOT, SCRIPTS, run, runCapture, runArgv, runCaptureArgv };
Loading