Skip to content
Closed
88 changes: 88 additions & 0 deletions bin/lib/deploy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 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 = {}) {
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 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 = {}) {
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,
};
71 changes: 70 additions & 1 deletion bin/lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,75 @@ 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([
"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, ...execOpts } = opts;
const { execFileSync } = require("child_process");
try {
return execFileSync(prog, args, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
...execOpts,
cwd: ROOT,
env: { ...process.env, ...sanitizeEnv(env) },
shell: false,
}).trim();
} catch (err) {
if (opts.ignoreError) return "";
throw err;
}
}

function runCapture(cmd, opts = {}) {
try {
return execSync(cmd, {
Expand All @@ -52,4 +121,4 @@ function runCapture(cmd, opts = {}) {
}
}

module.exports = { ROOT, SCRIPTS, run, runCapture };
module.exports = { ROOT, SCRIPTS, run, runCapture, runArgv, runCaptureArgv };
50 changes: 31 additions & 19 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ──────────────────────────────────────────────

Expand Down Expand Up @@ -62,6 +63,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("");
Expand All @@ -83,17 +85,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) {
Expand All @@ -105,31 +108,39 @@ 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");
Comment on lines +137 to +141
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

Quote the secret values before sourcing .env on the VM.

These commands still do set -a && . .env, which means the uploaded file is executed as shell. The file is built from raw token values above, so a credential containing $(), backticks, quotes, or ; will be parsed on the remote host and can also break deploys for perfectly valid secret values.

💡 Proposed fix
-  const envLines = [`NVIDIA_API_KEY=${process.env.NVIDIA_API_KEY}`];
+  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)}`);

Also applies to: 139-139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/nemoclaw.js` around lines 129 - 133, The deploy runs use runSsh with the
unsafe pattern "set -a && . .env" which executes raw .env contents on the VM;
change those runSsh calls (the ones invoking "cd /home/ubuntu/nemoclaw && set -a
&& . .env && set +a && bash scripts/brev-setup.sh" and the similar
start-services.sh invocation) to load the .env without executing it by
converting each line into an export with the value single-quoted (i.e.
produce/export lines like KEY='value' for each non-comment line) before running
the scripts so embedded shell metacharacters in secrets are not executed.

}

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 });
}

async function start() {
Expand Down Expand Up @@ -188,8 +199,9 @@ function listSandboxes() {

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)}`);

}

function sandboxStatus(sandboxName) {
Expand All @@ -204,7 +216,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);
Expand All @@ -217,7 +229,7 @@ function sandboxStatus(sandboxName) {

function sandboxLogs(sandboxName, follow) {
const followFlag = follow ? " --follow" : "";
run(`openshell sandbox logs "${sandboxName}"${followFlag}`);
run(`openshell sandbox logs ${shellQuote(sandboxName)}${followFlag}`);
}

async function sandboxPolicyAdd(sandboxName) {
Expand Down Expand Up @@ -260,7 +272,7 @@ function sandboxDestroy(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`);
Expand Down
Loading