Repository: NVIDIA/NemoClaw (2026-03-18T17:52:30Z)

Issue:
[Security] NVIDIA API key exposed in process list when creating inference provider
During onboarding, `openshell provider create` is called with the API key interpolated directly into the shell command:

```
openshell provider create --name nvidia-nim --type openai \
  --credential "NVIDIA_API_KEY=${process.env.NVIDIA_API_KEY}" ...
```

While this command is running, any user on the machine can see the full API key via `ps aux`. On shared systems (e.g., DGX Spark), this is a real credential exposure risk.

**Expected:** Pass the credential via stdin, environment variable, or a temporary file with restricted permissions — not as a command-line argument.

Related PRs that partially address this:
- #148 — replace shell string interpolation with argv arrays
- #191 — use provider-specific credential environment variable


Discussion:

Patch:
diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js
index 252a303c8d..4a8e171476 100644
--- a/bin/lib/onboard.js
+++ b/bin/lib/onboard.js
@@ -808,7 +808,7 @@ async function setupInference(sandboxName, model, provider) {
     // Create nvidia-nim provider
     run(
       `openshell provider create --name nvidia-nim --type openai ` +
-      `--credential ${shellQuote("NVIDIA_API_KEY=" + process.env.NVIDIA_API_KEY)} ` +
+      `--credential ${shellQuote("NVIDIA_API_KEY")} ` +
       `--config "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1" 2>&1 || true`,
       { ignoreError: true }
     );
@@ -824,10 +824,12 @@ async function setupInference(sandboxName, model, provider) {
     }
     const baseUrl = getLocalProviderBaseUrl(provider);
     run(
+      `OPENAI_API_KEY=dummy ` +
       `openshell provider create --name vllm-local --type openai ` +
-      `--credential "OPENAI_API_KEY=dummy" ` +
+      `--credential "OPENAI_API_KEY" ` +
       `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` +
-      `openshell provider update vllm-local --credential "OPENAI_API_KEY=dummy" ` +
+      `OPENAI_API_KEY=dummy ` +
+      `openshell provider update vllm-local --credential "OPENAI_API_KEY" ` +
       `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`,
       { ignoreError: true }
     );
@@ -844,10 +846,12 @@ async function setupInference(sandboxName, model, provider) {
     }
     const baseUrl = getLocalProviderBaseUrl(provider);
     run(
+      `OPENAI_API_KEY=ollama ` +
       `openshell provider create --name ollama-local --type openai ` +
-      `--credential "OPENAI_API_KEY=ollama" ` +
+      `--credential "OPENAI_API_KEY" ` +
       `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` +
-      `openshell provider update ollama-local --credential "OPENAI_API_KEY=ollama" ` +
+      `OPENAI_API_KEY=ollama ` +
+      `openshell provider update ollama-local --credential "OPENAI_API_KEY" ` +
       `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`,
       { ignoreError: true }
     );
diff --git a/nemoclaw-blueprint/orchestrator/runner.py b/nemoclaw-blueprint/orchestrator/runner.py
index 432c228c3b..bd96794e66 100644
--- a/nemoclaw-blueprint/orchestrator/runner.py
+++ b/nemoclaw-blueprint/orchestrator/runner.py
@@ -209,8 +209,10 @@ def action_apply(
         "--type",
         provider_type,
     ]
+    target_cred_env = "OPENAI_API_KEY"
     if credential:
-        provider_args.extend(["--credential", f"OPENAI_API_KEY={credential}"])
+        os.environ[target_cred_env] = credential
+        provider_args.extend(["--credential", target_cred_env])
     if endpoint:
         provider_args.extend(["--config", f"OPENAI_BASE_URL={endpoint}"])
 


Test patch:
diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js
new file mode 100644
index 0000000000..8718eb2f51
--- /dev/null
+++ b/test/credential-exposure.test.js
@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+// Security regression test: credential values must never appear in --credential
+// CLI arguments. OpenShell reads credential values from the environment when
+// only the env-var name is passed (e.g. --credential "NVIDIA_API_KEY"), so
+// there is no reason to pass the secret itself on the command line where it
+// would be visible in `ps aux` output.
+
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+
+const ONBOARD_JS = path.join(__dirname, "..", "bin", "lib", "onboard.js");
+const RUNNER_PY = path.join(
+  __dirname,
+  "..",
+  "nemoclaw-blueprint",
+  "orchestrator",
+  "runner.py",
+);
+
+// Matches --credential followed by a value containing "=" (i.e. KEY=VALUE).
+// Catches quoted KEY=VALUE patterns in JS and Python f-string interpolation.
+// Assumes credentials are always in quoted strings (which matches our codebase).
+// NOTE: unquoted forms like `--credential KEY=VALUE` would not be detected.
+const JS_EXPOSURE_RE = /--credential\s+[^"]*"[A-Z_]+=/;
+const JS_CREDENTIAL_CONCAT_RE = /--credential.*=.*process\.env\./;
+const PY_EXPOSURE_RE = /--credential.*=.*\{/;
+
+describe("credential exposure in process arguments", () => {
+  it("onboard.js must not pass KEY=VALUE to --credential", () => {
+    const src = fs.readFileSync(ONBOARD_JS, "utf-8");
+    const lines = src.split("\n");
+
+    const violations = lines.filter(
+      (line) =>
+        (JS_EXPOSURE_RE.test(line) || JS_CREDENTIAL_CONCAT_RE.test(line)) &&
+        // Allow comments that describe the old pattern
+        !line.trimStart().startsWith("//"),
+    );
+
+    expect(violations).toEqual([]);
+  });
+
+  it("runner.py must not pass KEY=VALUE to --credential", () => {
+    const src = fs.readFileSync(RUNNER_PY, "utf-8");
+    const lines = src.split("\n");
+
+    const violations = lines.filter(
+      (line) =>
+        PY_EXPOSURE_RE.test(line) &&
+        line.includes("--credential") &&
+        !line.trimStart().startsWith("#"),
+    );
+
+    expect(violations).toEqual([]);
+  });
+
+  it("onboard.js --credential flags pass env var names only", () => {
+    const src = fs.readFileSync(ONBOARD_JS, "utf-8");
+
+    // Find all --credential arguments and verify they contain only a key name
+    // (no "=" sign in the credential value)
+    const credentialArgs = src.match(/--credential\s+"([^"]+)"/g) || [];
+    const credentialShellQuote =
+      src.match(/--credential\s+\$\{shellQuote\("([^"]+)"\)\}/g) || [];
+
+    const allArgs = [...credentialArgs, ...credentialShellQuote];
+    expect(allArgs.length).toBeGreaterThan(0);
+
+    for (const arg of allArgs) {
+      // Extract the credential value from the match
+      const valueMatch =
+        arg.match(/--credential\s+"([^"]+)"/) ||
+        arg.match(/--credential\s+\$\{shellQuote\("([^"]+)"\)\}/);
+      if (valueMatch) {
+        expect(valueMatch[1]).not.toContain("=");
+      }
+    }
+  });
+});