diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index b625069ae8..a429fd56a6 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -89,20 +89,25 @@ Instead, collect the required choices from me first, one clickable selection at ## Handle Tokens Securely and Visually -When you need an API key, bot token, app token, or other secret, prefer the checked-in NemoClaw local credential form instead of chat. - -- Ask permission before opening a local credential form. -- Do not generate, rewrite, or redesign credential-form HTML. Use the repository form template from this URL exactly: https://raw.githubusercontent.com/NVIDIA/NemoClaw/c9aac7dc12bacdaa4d38af552b893021049ee836/docs/resources/local-credential-form.html -- Fetch that template or use the local repo copy when available, verify its SHA-256 digest is \`cc746703ab514cf33d7131915f16e8dc19346b26a4d953c5125be81449d6e6f6\`, write the exact bytes into a private temporary directory, then serve it from a helper bound to \`127.0.0.1\` on a random local port. -- Treat that immutable URL and digest as one reviewed trust boundary. Stop if verification fails; do not substitute a different URL, template, or digest. -- Open the served loopback URL, not the raw GitHub URL, in your coding-agent UI's browser. Configure fields with query parameters such as \`?fields=NVIDIA_INFERENCE_API_KEY:secret\` or \`?fields=NEMOCLAW_ENDPOINT_URL:text,NEMOCLAW_MODEL:text,COMPATIBLE_API_KEY:secret\`. -- Implement only the tiny loopback helper around the template: serve the HTML file, accept its \`POST /submit\` JSON payload, keep submitted values in memory, and expose no external network listener. When serving the HTML response, include the HTTP header \`Content-Security-Policy: frame-ancestors 'none'\` because browsers do not enforce that directive from a meta tag. -- Use \`:secret\` fields for secret values and \`:text\` fields for non-secret IDs such as server IDs, allowlists, endpoint URLs, and sandbox names. -- Keep submitted secrets only in memory long enough to run the approved command. Do not print them, write them to logs, commit them, or paste them into chat. -- If you must write a temporary file for the helper, use a private temporary directory, restrict permissions when possible, and delete it immediately after use. -- Show me a redacted summary before running commands, such as \`TELEGRAM_BOT_TOKEN=********\`, and ask permission to continue. -- After the command finishes, shut down the local helper and delete the temporary HTML file. -- If your environment cannot serve a loopback helper, use the local terminal, a secure secret prompt, or the local app prompt that needs the credential. Do not fall back to generated form HTML. +When you need an API key, bot token, app token, or other secret, use the checked-in NemoClaw local credential helper and form instead of chat. + +- Before collecting anything, decide the exact environment-variable names and exact command argv that will receive them. Explain the command in plain language, say that the form's final confirmation runs that already-approved command immediately, and ask my permission. +- Do not generate, rewrite, or redesign the helper or form. Use this reviewed pair exactly, and verify both SHA-256 digests before use: + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/dd61a307d7ddf7be99de8ff1e2678fb8ef42f8e6/scripts/local-credential-helper.mts (SHA-256 \`1a42bbe8dbc9003cb79d4e641b53760571aacd85293671aee97c09c0746fef33\`) + - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/dd61a307d7ddf7be99de8ff1e2678fb8ef42f8e6/docs/resources/local-credential-form.html (SHA-256 \`5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8\`) +- Treat the two immutable URL and digest pairs as one reviewed trust boundary. Stop if either verification fails; do not substitute another URL, helper, form, or digest. Put fetched copies in a private temporary directory restricted to the current user. +- The helper requires Node.js 22.16 or newer. If that runtime is unavailable, use a secure local terminal prompt or local app prompt instead; never ask for the value in chat and never fall back to generated code. +- Run the helper with \`--execution-profile isolated\` for stateless commands. Pass one \`--field NAME:type\` per value, then a literal \`--\` and the exact approved argv. The helper serves a one-time \`http://127.0.0.1\` form, accepts a single submission, then runs that argv; it enforces loopback-only access, requires an absolute executable, and strips ambient credential and process-control variables. Never put credentials in argv. For example: + +\`\`\`shell +node --experimental-strip-types /local-credential-helper.mts --execution-profile isolated --form /local-credential-form.html --field NVIDIA_INFERENCE_API_KEY:secret -- +\`\`\` + +- Use \`:secret\` for every secret and \`:text\` only for non-secret IDs, endpoint URLs, model names, and sandbox names. +- Open only the one-time \`http://127.0.0.1\` URL the helper prints. In the form, enter the values and choose **Preview Credentials** for a redacted local-only summary, or **Edit** to re-enter them. Choose **Confirm and Run Approved Command** once that summary matches the command I approved. +- If the form says the outcome is unknown, do not retry or resubmit. Check the coding-agent terminal to see whether the command ran, then start a fresh helper session only if needed. +- Keep secrets in memory only long enough to start the command; treat this as exposure minimization, not guaranteed erasure. Do not print, log, commit, or paste them into chat, and delete the fetched copies afterward. +- For a command that must persist account state, such as a NemoClaw install or onboarding run, prefer letting that command prompt for the credential itself. If you use the helper instead, run it with \`--execution-profile account-home --cwd \` and ask my permission for both paths. Do not hand-assemble a \`curl | bash\` wrapper. Use this provider mapping for non-interactive setup: @@ -118,7 +123,7 @@ Use this provider mapping for non-interactive setup: | Local Ollama | \`ollama\` | Optional \`NEMOCLAW_MODEL\`; set \`NEMOCLAW_YES=1\` only if I approve model download | | Model Router | \`routed\` | \`NVIDIA_INFERENCE_API_KEY\` | -When you have the approved values, run the installer with the environment variables on the \`bash\` side of the pipe, not before \`curl\`. +When you have the approved values, run the installer with the credentials in the environment on the \`bash\` side of the pipe, not before \`curl\`, and never in a command echoed to chat. For an install-time credential, prefer the installer's own secure prompt over routing it through the helper. Do not offer the Hermes Provider option for OpenClaw or Deep Agents. For example, for an approved Local Ollama setup: diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index f3f38f6a28..c70f30d844 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -28,7 +28,7 @@ The first Hermes build can take several minutes because NemoClaw builds the Herm Copy the starter prompt into Cursor, Claude Code, Codex, Copilot, or another local coding agent when you want the assistant to install NemoClaw with you. The prompt points your agent to [AI Agent Docs](../resources/agent-skills), this quickstart, the Markdown docs, and the optional `nemoclaw-user-guide` skill. -It also asks your agent to confirm Hermes as the selected agent before it builds the install or onboard command, and to reuse the checked-in local credential form for secrets. +It also asks your agent to confirm Hermes as the selected agent before it builds the install or onboard command, and to use the checked-in local credential helper and form after you approve the exact command that will receive the credentials. diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index a7bf124d9d..6c0261c04c 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -30,7 +30,7 @@ On macOS, start Docker Desktop or Colima before you run the installer. Copy the starter prompt into Cursor, Claude Code, Codex, Copilot, or another local coding agent when you want the assistant to install NemoClaw with you. The prompt points your agent to [AI Agent Docs](../resources/agent-skills), this quickstart, the Markdown docs, and the optional `nemoclaw-user-guide` skill. -It also asks your agent to confirm LangChain Deep Agents Code as the selected agent before it builds the install or onboard command. +It also asks your agent to confirm LangChain Deep Agents Code as the selected agent before it builds the install or onboard command, and to use the checked-in local credential helper and form after you approve the exact command that will receive the credentials. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 40327dc4c1..6b8168b6fd 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -24,7 +24,7 @@ Review the [Prerequisites](prerequisites) before following this guide. Copy the starter prompt into Cursor, Claude Code, Codex, Copilot, or another local coding agent when you want the assistant to install NemoClaw with you. The prompt points your agent to [AI Agent Docs](../resources/agent-skills), this quickstart, the Markdown docs, and the optional `nemoclaw-user-guide` skill. -It also tells your agent to collect choices before launching interactive commands and to reuse the checked-in local credential form for secrets. +It also tells your agent to collect choices before launching interactive commands and to use the checked-in local credential helper and form after you approve the exact command that will receive the credentials. diff --git a/docs/index.mdx b/docs/index.mdx index 715fd285b7..c604ddd2ea 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -45,7 +45,7 @@ Install NemoClaw and run the onboard wizard to get started. ### From Your Coding Agent Copy the starter prompt and paste it into your local coding agent, such as Cursor, Claude Code, Codex, Copilot, or another assistant that can run local commands with your approval. -The prompt tells your agent to use NemoClaw skills when available, bootstrap the docs-routing skill when it is missing, fetch the Markdown docs, ask whether you want OpenClaw, Hermes, or Deep Agents, collect choices one question at a time, and reuse the checked-in local credential form instead of asking you to paste secrets into chat. +The prompt tells your agent to use NemoClaw skills when available, bootstrap the docs-routing skill when it is missing, fetch the Markdown docs, ask whether you want OpenClaw, Hermes, or Deep Agents, collect choices one question at a time, and use the checked-in local credential helper and form after you approve the exact command that will receive the credentials. diff --git a/docs/resources/agent-skills.mdx b/docs/resources/agent-skills.mdx index da13e871bc..8715a9bf67 100644 --- a/docs/resources/agent-skills.mdx +++ b/docs/resources/agent-skills.mdx @@ -20,7 +20,7 @@ Use this page when you want your agent to help with installation, inference conf ## Give Your Agent the Starter Prompt The fastest path is to copy the starter prompt from the NemoClaw home page and paste it into your local coding agent. -The prompt tells the agent to use NemoClaw skills when available, bootstrap the docs-routing skill when missing, use the Markdown docs, ask one question at a time, run commands only with permission, and reuse the checked-in local credential form for secrets. +The prompt tells the agent to use NemoClaw skills when available, bootstrap the docs-routing skill when missing, use the Markdown docs, ask one question at a time, run commands only with permission, and use the checked-in local credential helper and form after you approve the exact command that will receive the credentials. NemoClaw keeps the prompt text in a shared docs source so the copy button and manual fallback render the same content. diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index e4998091dc..98ca7a9f91 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -9,7 +9,7 @@ NemoClaw Local Credential Form @@ -140,33 +140,201 @@

NemoClaw Local Credential Form

- + + +
diff --git a/package.json b/package.json index 86df0f0dd8..68cccd454d 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "nemoclaw/package.json", "nemoclaw-blueprint/", "scripts/", + "docs/resources/local-credential-form.html", "Dockerfile", ".dockerignore" ], diff --git a/scripts/checks/local-credential-helper-pin.ts b/scripts/checks/local-credential-helper-pin.ts new file mode 100644 index 0000000000..344394dcdd --- /dev/null +++ b/scripts/checks/local-credential-helper-pin.ts @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Verifies that the starter prompt pins the reviewed credential helper and form bytes. + * + * NemoClaw uses squash-only merges, so the intermediate artifact commit is not + * an ancestor of the merged commit and may be absent from shallow checkouts. + * This check therefore binds each local file to its advertised SHA-256 and a + * full immutable URL. The prompt verifies fetched bytes and fails closed if + * GitHub cannot serve that intermediate commit. + */ + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const STARTER_PROMPT_PATH = "docs/_components/StarterPrompt.tsx"; +const HELPER_PATH = "scripts/local-credential-helper.mts"; +const FORM_PATH = "docs/resources/local-credential-form.html"; +const CREDENTIAL_ENV_PATH = "src/lib/security/credential-env.ts"; +const PROCESS_CONTROL_ENV_PATH = "src/lib/security/process-control-env.ts"; + +type ReviewedArtifact = Readonly<{ + label: string; + relativePath: string; +}>; + +const REVIEWED_ARTIFACTS: readonly ReviewedArtifact[] = [ + { label: "helper", relativePath: HELPER_PATH }, + { label: "form", relativePath: FORM_PATH }, +]; + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function findCredentialSection(promptSource: string): string { + const match = promptSource.match( + /## Handle Tokens Securely and Visually([\s\S]*?)\nUse this provider mapping/, + ); + if (!match?.[1]) throw new Error("Starter prompt credential section is missing"); + return match[1]; +} + +function verifyArtifact(section: string, artifact: ReviewedArtifact): string[] { + const failures: string[] = []; + const currentBytes = fs.readFileSync(path.join(REPO_ROOT, artifact.relativePath)); + const currentDigest = sha256(currentBytes); + const urlPattern = new RegExp( + `https://raw\\.githubusercontent\\.com/NVIDIA/NemoClaw/([0-9a-f]{40})/${escapeRegExp(artifact.relativePath)}`, + "g", + ); + const matches = [...section.matchAll(urlPattern)]; + const match = matches[0]; + if (matches.length !== 1 || !match?.[1] || match.index === undefined) { + return [`${artifact.label}: expected exactly one immutable raw GitHub URL`]; + } + + const lineStart = section.lastIndexOf("\n", match.index) + 1; + const nextLine = section.indexOf("\n", match.index); + const pinnedLine = section.slice(lineStart, nextLine < 0 ? undefined : nextLine); + if (!pinnedLine.includes(currentDigest)) { + failures.push(`${artifact.label}: immutable URL is not paired with SHA-256 ${currentDigest}`); + } + return failures; +} + +function verifyPackageFiles(): string[] { + const packageJson = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")) as { + files?: unknown; + }; + if (!Array.isArray(packageJson.files)) return ["package.json: files must be an array"]; + const failures: string[] = []; + if (!packageJson.files.includes("scripts/")) { + failures.push("package.json: scripts/ must ship the credential helper"); + } + if (!packageJson.files.includes(FORM_PATH)) { + failures.push(`package.json: ${FORM_PATH} must ship with the helper`); + } + if ((fs.statSync(path.join(REPO_ROOT, HELPER_PATH)).mode & 0o111) === 0) { + failures.push(`${HELPER_PATH}: helper must remain executable`); + } + return failures; +} + +function verifyEmbeddedFormDigest(): string[] { + const helperSource = fs.readFileSync(path.join(REPO_ROOT, HELPER_PATH), "utf8"); + const embeddedDigest = extractEmbeddedFormDigest(helperSource, HELPER_PATH); + const formDigest = sha256(fs.readFileSync(path.join(REPO_ROOT, FORM_PATH))); + return embeddedDigest === formDigest + ? [] + : [`${HELPER_PATH}: embedded form digest does not match ${FORM_PATH}`]; +} + +function executableSourceFile(source: string, relativePath: string): ts.SourceFile { + if (!relativePath.endsWith(".html")) { + const scriptKind = relativePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + return ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, scriptKind); + } + const scripts = [...source.matchAll(/`, + helper, + }; +} + +describe("local credential helper pin predicate parity", () => { + it("accepts exact canonical helper and form field-safety parity (#5048)", () => { + expect(verifyFieldSafetySourceParity(fieldSafetySources())).toEqual([]); + }); + + it("detects coordinated helper and form credential-pattern drift (#5048)", () => { + const failures = verifyFieldSafetySourceParity( + fieldSafetySources({ embeddedPattern: "/drifted/i" }), + ); + + expect(failures).toContain( + "helper credential-shaped name pattern must match the canonical security policy", + ); + expect(failures).toContain( + "form credential-shaped name pattern must match the canonical security policy", + ); + }); + + it("detects coordinated helper and form process-control inventory drift (#5048)", () => { + const failures = verifyFieldSafetySourceParity(fieldSafetySources({ embeddedNames: ["PATH"] })); + + expect(failures).toContain( + "helper process-control environment names must match the canonical security policy", + ); + expect(failures).toContain( + "form process-control environment names must match the canonical security policy", + ); + }); + + it("detects coordinated helper and form process-control predicate drift (#5048)", () => { + const failures = verifyFieldSafetySourceParity( + fieldSafetySources({ embeddedPrefixes: PARITY_PREFIXES.slice(1) }), + ); + + expect(failures).toContain( + "helper process-control predicate must match the canonical security policy", + ); + expect(failures).toContain( + "form process-control predicate must match the canonical security policy", + ); + }); + + it("extracts every active process-control rule independent of OR order (#5048)", () => { + expect(extractRules(predicateSource(VALID_ATOMS.join(" || ")))).toEqual(EXPECTED_RULES); + expect(extractRules(predicateSource([...VALID_ATOMS].reverse().join(" || ")))).toEqual( + EXPECTED_RULES, + ); + }); + + it.each([ + { atom: VALID_ATOMS[1], label: "BASH_FUNC_ prefix" }, + { atom: VALID_ATOMS[2], label: "LD_ prefix" }, + { atom: VALID_ATOMS[3], label: "DYLD_ prefix" }, + { atom: VALID_ATOMS[4], label: "exact GIT_CONFIG name" }, + { atom: VALID_ATOMS[5], label: "GIT_CONFIG_ prefix" }, + { atom: VALID_ATOMS[6], label: "GIT_TRACE prefix" }, + { atom: VALID_ATOMS[7], label: "NPM_CONFIG_ prefix" }, + { atom: VALID_ATOMS[8], label: "OPENSHELL_ prefix" }, + { atom: VALID_ATOMS[9], label: "PIP_ prefix" }, + ])("detects removal of the $label (#5048)", ({ atom }) => { + const mutated = VALID_ATOMS.filter((candidate) => candidate !== atom).join(" || "); + + expect(extractRules(predicateSource(mutated))).not.toEqual(EXPECTED_RULES); + }); + + it("distinguishes an exact-name rule from a broader prefix rule (#5048)", () => { + const mutated = VALID_ATOMS.map((atom) => + atom === 'name === "GIT_CONFIG"' ? 'name.startsWith("GIT_CONFIG")' : atom, + ).join(" || "); + + expect(extractRules(predicateSource(mutated))).not.toEqual(EXPECTED_RULES); + }); + + it("rejects rule-shaped statements after an early return (#5048)", () => { + const unreachableRules = VALID_ATOMS.slice(1) + .map((atom) => `${atom};`) + .join("\n "); + const source = `function ${FUNCTION_NAME}(name) {\n return ${VALID_ATOMS[0]};\n ${unreachableRules}\n}`; + + expect(() => extractRules(source)).toThrow("must contain exactly one return expression"); + }); + + it.each([ + { + decoy: `/* ${predicateSource(VALID_ATOMS.join(" || "))} */`, + label: "block-commented function", + }, + { + decoy: `const decoy = ${JSON.stringify(predicateSource(VALID_ATOMS.join(" || ")))};`, + label: "string-embedded function", + }, + ])("ignores a $label before the executable predicate (#5048)", ({ decoy }) => { + const livePredicate = predicateSource(VALID_ATOMS[0]); + + expect(extractRules(`${decoy}\n${livePredicate}`)).toEqual(["literal-set"]); + }); + + it.each([ + { + label: "async declaration", + source: predicateSource(VALID_ATOMS.join(" || ")).replace("function", "async function"), + }, + { + label: "generator declaration", + source: predicateSource(VALID_ATOMS.join(" || ")).replace("function ", "function* "), + }, + { + label: "wrong parameter name", + source: predicateSource(VALID_ATOMS.join(" || ")).replace("(name)", "(other)"), + }, + { + label: "default parameter", + source: predicateSource(VALID_ATOMS.join(" || ")).replace("(name)", '(name = "")'), + }, + { + label: "rest parameter", + source: predicateSource(VALID_ATOMS.join(" || ")).replace("(name)", "(...name)"), + }, + ])("rejects a $label (#5048)", ({ source }) => { + expect(() => extractRules(source)).toThrow("must be a synchronous one-argument predicate"); + }); + + it.each([ + { + decoy: "/* const CREDENTIAL_SHAPED_NAME_PATTERN = /old/i; */", + label: "block-commented pattern", + }, + { + decoy: 'const decoy = "const CREDENTIAL_SHAPED_NAME_PATTERN = /old/i;";', + label: "string-embedded pattern", + }, + ])("ignores a $label before the executable credential pattern (#5048)", ({ decoy }) => { + const source = `${decoy}\nconst CREDENTIAL_SHAPED_NAME_PATTERN = /current_[A-Z]+/i;`; + + expect(extractCredentialPattern(source, "fixture.ts")).toBe("/current_[A-Z]+/i"); + }); + + it("extracts executable source from case-insensitive HTML script tags (#5048)", () => { + const source = ""; + + expect(extractCredentialPattern(source, "fixture.html")).toBe("/current_[A-Z]+/i"); + }); + + it.each([ + { + decoy: '/* const BLOCKED_NAMES = new Set(["OLD"]); */', + label: "block-commented set", + }, + { + decoy: `'const BLOCKED_NAMES = new Set(["OLD"]);';`, + label: "string-embedded set", + }, + ])("ignores a $label before the executable process-control set (#5048)", ({ decoy }) => { + const source = `${decoy}\nconst BLOCKED_NAMES = new Set(["CURRENT"]);`; + + expect(extractStringSet(source, SET_NAME, "fixture.ts")).toEqual(["CURRENT"]); + }); + + it("ignores commented-out entries inside the executable process-control set (#5048)", () => { + const source = 'const BLOCKED_NAMES = new Set([/* "OLD", */ "CURRENT"]);'; + + expect(extractStringSet(source, SET_NAME, "fixture.ts")).toEqual(["CURRENT"]); + }); + + it.each([ + { + decoy: `/* const EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256 = "${"b".repeat(64)}"; */`, + label: "block-commented digest", + }, + { + decoy: `const decoy = ${JSON.stringify( + `const EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256 = "${"b".repeat(64)}";`, + )};`, + label: "string-embedded digest", + }, + ])("ignores a $label before the executable form digest (#5048)", ({ decoy }) => { + const currentDigest = "a".repeat(64); + const source = `${decoy}\nconst EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256 = "${currentDigest}";`; + + expect(extractEmbeddedFormDigest(source, "fixture.ts")).toBe(currentDigest); + }); + + it.each([ + { + decoy: "/* export const STARTER_PROMPT = `stale prompt`; */", + label: "block-commented prompt", + }, + { + decoy: `const decoy = ${JSON.stringify("export const STARTER_PROMPT = `stale prompt`;")};`, + label: "string-embedded prompt", + }, + ])("ignores a $label before the executable starter prompt (#5048)", ({ decoy }) => { + const source = `${decoy}\nexport const STARTER_PROMPT = \`current prompt\`;`; + + expect(extractStarterPrompt(source, "fixture.tsx")).toBe("current prompt"); + }); +}); diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts new file mode 100644 index 0000000000..b942ca664a --- /dev/null +++ b/test/local-credential-helper.test.ts @@ -0,0 +1,1258 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type ChildProcess, execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import http, { type IncomingHttpHeaders } from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildCredentialFormCsp, + parseCredentialField, + sanitizeInheritedChildEnvironment, + startLocalCredentialHelper, +} from "../scripts/local-credential-helper.mts"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const HELPER_PATH = path.join(REPO_ROOT, "scripts", "local-credential-helper.mts"); +const FORM_PATH = path.join(REPO_ROOT, "docs", "resources", "local-credential-form.html"); +const READINESS_URL_PATTERN = /http:\/\/127\.0\.0\.1:\d+\/\S*#cap=[A-Za-z0-9_-]{43}/; +const PROCESS_TIMEOUT_MS = 5_000; +const TEST_SECRET = "integration-secret-value"; +const TEST_PUBLIC_VALUE = "public-id"; +const CONFIG_ROOT_ENV_NAMES = [ + "ALLUSERSPROFILE", + "APPDATA", + "CURL_HOME", + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "GCONV_PATH", + "GIT_COMMON_DIR", + "GIT_DIR", + "GLIBC_TUNABLES", + "HOME", + "HOMEDRIVE", + "HOMEPATH", + "KUBECONFIG", + "LOCALAPPDATA", + "LOCPATH", + "NETRC", + "NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL", + "NEMOCLAW_BOOTSTRAP_PAYLOAD", + "NEMOCLAW_INSTALL_REF", + "NEMOCLAW_INSTALL_TAG", + "NEMOCLAW_INSTALLER_STAGED", + "NEMOCLAW_INSTALLER_URL", + "NEMOCLAW_OPENSHELL_BIN", + "NEMOCLAW_OPENSHELL_CHANNEL", + "NEMOCLAW_OPENSHELL_GATEWAY_BIN", + "NEMOCLAW_OPENSHELL_SANDBOX_BIN", + "NEMOCLAW_REPO_ROOT", + "NEMOCLAW_SOURCE_ROOT", + "NVM_DIR", + "OLDPWD", + "OPENSSL_CONF", + "OPENSSL_CONF_INCLUDE", + "OPENSSL_ENGINES", + "OPENSSL_MODULES", + "PROGRAMDATA", + "PSMODULEPATH", + "PWD", + "PYTHONUSERBASE", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "VIRTUAL_ENV", + "XDG_BIN_HOME", + "XDG_CACHE_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", + "ZDOTDIR", +]; +const CONFIG_ROOT_ENV_OVERRIDES = Object.fromEntries( + CONFIG_ROOT_ENV_NAMES.map((name) => [name, `/ambient/${name.toLowerCase()}`]), +) satisfies NodeJS.ProcessEnv; +const PRIVATE_EXECUTION_ENV_PATHS = { + APPDATA: ["appdata", "roaming"], + CURL_HOME: ["config"], + HOME: [], + LOCALAPPDATA: ["appdata", "local"], + PWD: [], + TEMP: ["tmp"], + TMP: ["tmp"], + TMPDIR: ["tmp"], + USERPROFILE: [], + XDG_CACHE_HOME: ["cache"], + XDG_CONFIG_DIRS: ["config-dirs"], + XDG_CONFIG_HOME: ["config"], + XDG_DATA_DIRS: ["data-dirs"], + XDG_DATA_HOME: ["data"], + XDG_RUNTIME_DIR: ["runtime"], + XDG_STATE_HOME: ["state"], +} as const; +const NETWORK_TRUST_ENV_NAMES = [ + "ALL_PROXY", + "AWS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "DENO_CERT", + "FTP_PROXY", + "GIT_PROXY_SSL_CAINFO", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", + "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", + "GRPC_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NO_PROXY", + "REQUESTS_CA_BUNDLE", + "SSLKEYLOGFILE", + "SSL_CERT_DIR", + "SSL_CERT_FILE", +]; +const NETWORK_TRUST_ENV_OVERRIDES = { + ...Object.fromEntries( + NETWORK_TRUST_ENV_NAMES.map((name) => [name, `ambient-${name.toLowerCase()}`]), + ), + GIT_SSL_NO_VERIFY: "1", + NODE_EXTRA_CA_CERTS: "", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + NO_PROXY: "*", +} satisfies NodeJS.ProcessEnv; +const PACKAGE_CONTROL_ENV_OVERRIDES = { + npm_config_registry: "https://ambient-registry.invalid", + Pip_Index_Url: "https://ambient-index.invalid/simple", +} satisfies NodeJS.ProcessEnv; +const BLOCKED_INHERITED_ENV_NAMES = [ + ...NETWORK_TRUST_ENV_NAMES, + ...Object.keys(PACKAGE_CONTROL_ENV_OVERRIDES), + "BASH_ENV", + "BASH_FUNC_echo%%", + "DOTNET_STARTUP_HOOKS", + "GIT_EXEC_PATH", + "GIT_CONFIG", + "GH_TOKEN", + "GIT_CONFIG_COUNT", + "GIT_EXTERNAL_DIFF", + "GIT_PROXY_COMMAND", + "GIT_TRACE2_EVENT", + "GIT_SSH", + "Gh_ToKeN", + "NODE_OPTIONS", + "Node_Options", + "NEMOCLAW_PROVIDER", + "OPENSHELL_DOCKER_SUPERVISOR_IMAGE", + "PATH", + "Path", + "UNRELATED_API_TOKEN", +]; + +interface ExitResult { + code: number | null; + signal: NodeJS.Signals | null; +} + +interface CapturedChild { + child: ChildProcess; + closed: Promise; + output(): string; +} + +interface RunningHelper extends CapturedChild { + capability: string; + formUrl: URL; +} + +interface HttpResult { + body: string; + headers: IncomingHttpHeaders; + status: number; +} + +interface RequestOptions { + body?: Buffer | string; + headers?: Record; + method?: string; + omitContentLength?: boolean; + path?: string; +} + +type ReadinessState = { kind: "exited" } | { kind: "ready"; url: string } | { kind: "waiting" }; + +const activeChildren = new Set(); +const tempDirs = new Set(); + +function privateExecutionRoots(): string[] { + return fs + .readdirSync(path.dirname(HELPER_PATH)) + .filter((name) => name.startsWith(".credential-child-")) + .sort(); +} + +afterEach(async () => { + await Promise.all([...activeChildren].map((captured) => terminate(captured))); + activeChildren.clear(); + for (const dir of tempDirs) fs.rmSync(dir, { force: true, recursive: true }); + tempDirs.clear(); +}); + +function captureChild( + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, + cwd = REPO_ROOT, +): CapturedChild { + const child = spawn(process.execPath, args, { + cwd, + env: { ...process.env, ...envOverrides, NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const closed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + const captured = { + child, + closed, + output: () => `${stdout}${stderr}`, + }; + activeChildren.add(captured); + return captured; +} + +function childHasExited(captured: CapturedChild): boolean { + return captured.child.exitCode !== null || captured.child.signalCode !== null; +} + +async function awaitClosed(captured: CapturedChild): Promise { + await captured.closed.catch(() => undefined); +} + +async function terminateRunning(captured: CapturedChild): Promise { + captured.child.kill("SIGTERM"); + try { + await withTimeout(captured.closed, 1_000, "credential helper SIGTERM"); + } catch { + captured.child.kill("SIGKILL"); + await awaitClosed(captured); + } +} + +async function terminate(captured: CapturedChild): Promise { + await (childHasExited(captured) ? awaitClosed(captured) : terminateRunning(captured)); +} + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function helperArgs( + fields: string[], + command: string[], + formPath = FORM_PATH, + executionProfile: "account-home" | "isolated" = "isolated", + commandCwd?: string, +): string[] { + const cwdArgs = commandCwd === undefined ? [] : ["--cwd", commandCwd]; + const args = [ + "--experimental-strip-types", + HELPER_PATH, + "--execution-profile", + executionProfile, + "--form", + formPath, + ...cwdArgs, + ]; + return [...args, ...fields.flatMap((field) => ["--field", field]), "--", ...command]; +} + +function readinessState(captured: CapturedChild): ReadinessState { + const url = captured.output().match(READINESS_URL_PATTERN)?.[0]; + return url !== undefined + ? { kind: "ready", url } + : childHasExited(captured) + ? { kind: "exited" } + : { kind: "waiting" }; +} + +async function waitForReadiness(captured: CapturedChild): Promise { + const deadline = Date.now() + PROCESS_TIMEOUT_MS; + while (Date.now() < deadline) { + const state = readinessState(captured); + switch (state.kind) { + case "ready": + return new URL(state.url); + case "exited": { + const result = await captured.closed; + throw new Error( + `credential helper exited before readiness (${result.code ?? result.signal}):\n${captured.output()}`, + ); + } + case "waiting": + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`credential helper did not report readiness:\n${captured.output()}`); +} + +async function startHelper( + command: string[], + envOverrides: NodeJS.ProcessEnv = {}, + executionProfile: "account-home" | "isolated" = "isolated", + commandCwd?: string, + helperCwd = REPO_ROOT, +): Promise { + const captured = captureChild( + helperArgs( + ["OPENAI_API_KEY:secret", "PUBLIC_ID:text"], + command, + FORM_PATH, + executionProfile, + commandCwd, + ), + envOverrides, + helperCwd, + ); + const formUrl = await waitForReadiness(captured); + const fragment = new URLSearchParams(formUrl.hash.slice(1)); + const capability = fragment.get("cap") ?? ""; + expect(capability).toMatch(/^[A-Za-z0-9_-]{43}$/); + return { ...captured, capability, formUrl }; +} + +function createCommandFixture(): { + command: string[]; + markerPath: string; + unexpectedShellPath: string; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-credential-helper-test-")); + tempDirs.add(dir); + const markerPath = path.join(dir, "command-runs.txt"); + const unexpectedShellPath = path.join(dir, "unexpected-shell-execution"); + const secretHash = createHash("sha256").update(TEST_SECRET).digest("hex"); + const fixture = [ + 'const fs = require("node:fs");', + 'const crypto = require("node:crypto");', + 'const path = require("node:path");', + "const [markerPath, publicValue, unexpectedShellPath] = process.argv.slice(1);", + 'const secret = process.env.OPENAI_API_KEY || "";', + 'const actualHash = crypto.createHash("sha256").update(secret).digest("hex");', + `if (actualHash !== ${JSON.stringify(secretHash)}) process.exit(21);`, + `if (process.env.PUBLIC_ID !== ${JSON.stringify(TEST_PUBLIC_VALUE)}) process.exit(22);`, + `if (publicValue !== ${JSON.stringify(TEST_PUBLIC_VALUE)}) process.exit(23);`, + 'if (!process.argv.includes("--")) process.exit(25);', + `if (${JSON.stringify(BLOCKED_INHERITED_ENV_NAMES)}.some((name) => Object.hasOwn(process.env, name))) process.exit(26);`, + `if (Object.values(process.env).some((value) => ${JSON.stringify(Object.values(CONFIG_ROOT_ENV_OVERRIDES))}.includes(value))) process.exit(27);`, + 'const privateRoot = process.env.HOME || "";', + `for (const [name, parts] of Object.entries(${JSON.stringify(PRIVATE_EXECUTION_ENV_PATHS)})) if (process.env[name] !== path.join(privateRoot, ...parts)) process.exit(28);`, + `if (${JSON.stringify(CONFIG_ROOT_ENV_NAMES.filter((name) => !Object.hasOwn(PRIVATE_EXECUTION_ENV_PATHS, name)))}.some((name) => Object.hasOwn(process.env, name))) process.exit(29);`, + "if (!path.isAbsolute(privateRoot) || process.cwd() !== privateRoot) process.exit(30);", + 'if (process.platform !== "win32" && (fs.statSync(privateRoot).mode & 0o077) !== 0) process.exit(31);', + 'if ([".curlrc", ".gitconfig", ".npmrc", ".netrc"].some((name) => fs.existsSync(path.join(privateRoot, name)))) process.exit(32);', + 'fs.writeFileSync(markerPath + ".private-root", privateRoot);', + 'fs.appendFileSync(markerPath, "ran\\n");', + "if (fs.existsSync(unexpectedShellPath)) process.exit(24);", + ].join(""); + return { + command: [ + process.execPath, + "-e", + fixture, + markerPath, + TEST_PUBLIC_VALUE, + unexpectedShellPath, + "--", + "opaque-command-argument", + ], + markerPath, + unexpectedShellPath, + }; +} + +function request(url: URL, options: RequestOptions = {}): Promise { + const body = typeof options.body === "string" ? Buffer.from(options.body, "utf8") : options.body; + const suppliedHeaders = options.headers ?? {}; + const hasContentLength = Object.keys(suppliedHeaders).some( + (name) => name.toLowerCase() === "content-length", + ); + const generatedHeaders = + body !== undefined && !hasContentLength && !options.omitContentLength + ? { "content-length": String(body.length) } + : {}; + const headers = { ...suppliedHeaders, ...generatedHeaders }; + + return new Promise((resolve, reject) => { + const clientRequest = http.request( + { + agent: false, + headers, + hostname: url.hostname, + method: options.method ?? "GET", + path: options.path ?? `${url.pathname}${url.search}`, + port: url.port, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => { + resolve({ + body: Buffer.concat(chunks).toString("utf8"), + headers: response.headers, + status: response.statusCode ?? 0, + }); + }); + }, + ); + clientRequest.setTimeout(3_000, () => { + clientRequest.destroy(new Error("credential helper request timed out")); + }); + clientRequest.on("error", reject); + clientRequest.end(body); + }); +} + +function validHeaders(helper: RunningHelper): Record { + return { + "content-type": "application/json", + origin: helper.formUrl.origin, + "x-nemoclaw-capability": helper.capability, + }; +} + +function validBody(): string { + return JSON.stringify({ + values: { + OPENAI_API_KEY: TEST_SECRET, + PUBLIC_ID: TEST_PUBLIC_VALUE, + }, + }); +} + +function expectNoCors(headers: IncomingHttpHeaders): void { + expect(headers["access-control-allow-origin"]).toBeUndefined(); + expect(headers["access-control-allow-credentials"]).toBeUndefined(); + expect(headers["access-control-allow-headers"]).toBeUndefined(); + expect(headers["access-control-allow-methods"]).toBeUndefined(); +} + +function expectRejected(result: HttpResult): void { + expect(result.status).toBeGreaterThanOrEqual(400); + expect(result.status).toBeLessThan(500); + expectNoCors(result.headers); + expect(result.body).not.toContain(TEST_SECRET); +} + +function expectHttpStatus(result: HttpResult, expected: number): void { + expect(result.status).toBe(expected); +} + +async function expectCompletionCode(completion: Promise, expected: number): Promise { + expect(await completion).toBe(expected); +} + +function commandRunCount(markerPath: string): number { + try { + return fs.readFileSync(markerPath, "utf8").split("\n").filter(Boolean).length; + } catch { + return 0; + } +} + +async function expectSuccessfulCompletion( + helper: RunningHelper, + markerPath: string, +): Promise { + const result = await withTimeout(helper.closed, PROCESS_TIMEOUT_MS, "credential helper exit"); + expect(result).toEqual({ code: 0, signal: null }); + expect(commandRunCount(markerPath)).toBe(1); + const privateRootMarker = `${markerPath}.private-root`; + const privateRootStillExists = + fs.existsSync(privateRootMarker) && fs.existsSync(fs.readFileSync(privateRootMarker, "utf8")); + expect(privateRootStillExists).toBe(false); +} + +describe("local credential helper", () => { + it("hashes inline form tags case-insensitively for CSP generation (#5048)", () => { + const csp = buildCredentialFormCsp(Buffer.from("")); + + expect(csp).toContain("script-src 'sha256-"); + expect(csp).toContain("style-src 'sha256-"); + }); + + it("drops every ambient entry without mutating the source environment (#5048)", () => { + const ambient = { + ...CONFIG_ROOT_ENV_OVERRIDES, + ...NETWORK_TRUST_ENV_OVERRIDES, + ...PACKAGE_CONTROL_ENV_OVERRIDES, + BASH_ENV: "shell-hook", + "BASH_FUNC_echo%%": '() { printf "%s" "$OPENAI_API_KEY"; }', + DOTNET_STARTUP_HOOKS: "startup-hook", + DYLD_INSERT_LIBRARIES: "loader-hook", + Gh_ToKeN: "credential", + GIT_CONFIG: "git-config", + GIT_CONFIG_COUNT: "1", + GIT_EXTERNAL_DIFF: "/ambient/diff-wrapper", + GIT_EXEC_PATH: "/ambient/git-core", + GIT_PROXY_COMMAND: "/ambient/proxy-wrapper", + GIT_TRACE2_EVENT: "/ambient/git-trace.json", + GIT_SSH: "/ambient/ssh-wrapper", + LD_PRELOAD: "loader-hook", + NEMOCLAW_PROVIDER: "ambient-provider", + Node_Options: "--no-warnings", + Path: "/ambient/search/path", + Public_Id: "ambient-field-collision", + UNKNOWN_AMBIENT_SETTING: "removed", + SSH_AUTH_SOCK: "/ambient/agent.sock", + } satisfies NodeJS.ProcessEnv; + const original = { ...ambient }; + + const sanitized = sanitizeInheritedChildEnvironment(ambient, new Set(["PUBLIC_ID"])); + + expect(sanitized).toEqual({}); + expect(ambient).toEqual(original); + }); + + it("allows explicitly collected NemoClaw settings while denying their ambient values (#5048)", () => { + expect(parseCredentialField("NEMOCLAW_PROVIDER:text")).toEqual({ + name: "NEMOCLAW_PROVIDER", + type: "text", + }); + expect( + sanitizeInheritedChildEnvironment( + { NEMOCLAW_MODEL: "ambient-model", NEMOCLAW_PROVIDER: "ambient-provider" }, + new Set(["NEMOCLAW_PROVIDER"]), + ), + ).toEqual({}); + }); + + it.each([ + { + fields: ["OPENAI_API_KEY:text"], + label: "secret-shaped field declared as text", + }, + { fields: ["PATH:text"], label: "process search path field" }, + { fields: ["NODE_OPTIONS:secret"], label: "Node process-control field" }, + { fields: ["BASH_FUNC_ECHO:secret"], label: "exported Bash function field prefix" }, + { fields: ["DOTNET_STARTUP_HOOKS:secret"], label: ".NET startup hook field" }, + { fields: ["GIT_EXEC_PATH:secret"], label: "Git executable path field" }, + { fields: ["GIT_EXTERNAL_DIFF:secret"], label: "Git external diff field" }, + { fields: ["GIT_PROXY_COMMAND:secret"], label: "Git proxy command field" }, + { fields: ["GIT_TRACE2_EVENT:secret"], label: "Git trace field prefix" }, + { fields: ["GIT_SSH:secret"], label: "Git SSH wrapper field" }, + { fields: ["LD_PRELOAD:secret"], label: "dynamic-loader field prefix" }, + { fields: ["DYLD_INSERT_LIBRARIES:secret"], label: "macOS dynamic-loader field prefix" }, + { fields: ["GIT_CONFIG:secret"], label: "exact Git config process-control field" }, + { fields: ["GIT_CONFIG_COUNT:secret"], label: "Git config process-control field prefix" }, + ...CONFIG_ROOT_ENV_NAMES.map((name) => ({ + fields: [`${name}:text`], + label: `${name} configuration-root field`, + })), + ...NETWORK_TRUST_ENV_NAMES.map((name) => ({ + fields: [`${name}:text`], + label: `${name} network or trust control field`, + })), + { fields: ["NPM_CONFIG_REGISTRY:text"], label: "npm configuration field prefix" }, + { + fields: ["OPENSHELL_DOCKER_SUPERVISOR_IMAGE:text"], + label: "OpenShell configuration field prefix", + }, + { fields: ["PIP_INDEX_URL:text"], label: "pip configuration field prefix" }, + { + fields: ["PUBLIC_ID:text", "PUBLIC_ID:text"], + label: "duplicate field", + }, + { + fields: Array.from({ length: 17 }, (_value, index) => `PUBLIC_ID_${index}:text`), + label: "field count above the session limit", + }, + { fields: ["bad-name:secret"], label: "malformed field name" }, + ])("rejects $label before listening (#5048)", async ({ fields }) => { + const captured = captureChild(helperArgs(fields, [process.execPath, "-e", "process.exit(0)"])); + + const result = await withTimeout(captured.closed, PROCESS_TIMEOUT_MS, "invalid helper CLI"); + + expect(result.code).not.toBe(0); + expect(captured.output()).not.toMatch(READINESS_URL_PATTERN); + }); + + it.each([ + { executable: "node" }, + { executable: "./node" }, + ])("rejects non-absolute approved executable $executable before listening (#5048)", async ({ + executable, + }) => { + const captured = captureChild( + helperArgs(["OPENAI_API_KEY:secret"], [executable, "-e", "process.exit(0)"]), + ); + + const result = await withTimeout(captured.closed, PROCESS_TIMEOUT_MS, "relative executable"); + + expect(result.code).not.toBe(0); + expect(captured.output()).toContain("approved command executable must use an absolute path"); + expect(captured.output()).not.toMatch(READINESS_URL_PATTERN); + }); + + it.each([ + { + options: [], + expected: "--execution-profile isolated or --execution-profile account-home is required", + }, + { + options: ["--execution-profile", "unknown"], + expected: "--execution-profile must be isolated or account-home", + }, + { + options: ["--execution-profile", "account-home"], + expected: "--execution-profile account-home requires an absolute --cwd path", + }, + { + options: ["--execution-profile", "account-home", "--cwd", "relative"], + expected: "--cwd must be an absolute path without NUL bytes", + }, + { + options: ["--execution-profile", "isolated", "--cwd", REPO_ROOT], + expected: "--execution-profile isolated does not accept --cwd", + }, + ])("rejects an unsafe or ambiguous execution profile before listening (#5048)", async ({ + expected, + options, + }) => { + const captured = captureChild([ + "--experimental-strip-types", + HELPER_PATH, + ...options, + "--field", + "OPENAI_API_KEY:secret", + "--", + process.execPath, + "-e", + "process.exit(0)", + ]); + + const result = await withTimeout(captured.closed, PROCESS_TIMEOUT_MS, "execution profile"); + + expect(result.code).not.toBe(0); + expect(captured.output()).toContain(expected); + expect(captured.output()).not.toMatch(READINESS_URL_PATTERN); + }); + + it("rejects a non-absolute executable through the direct session API (#5048)", async () => { + await expect( + startLocalCredentialHelper({ + commandArgv: ["node", "-e", "process.exit(0)"], + executionProfile: "isolated", + fields: [{ name: "OPENAI_API_KEY", type: "secret" }], + formBytes: fs.readFileSync(FORM_PATH), + }), + ).rejects.toThrow("approved command executable must use an absolute path"); + }); + + it("rejects a missing or unknown execution profile through the direct session API (#5048)", async () => { + await expect( + startLocalCredentialHelper({ + commandArgv: [process.execPath, "-e", "process.exit(0)"], + executionProfile: "unknown" as "isolated", + fields: [{ name: "OPENAI_API_KEY", type: "secret" }], + formBytes: fs.readFileSync(FORM_PATH), + }), + ).rejects.toThrow("execution profile must be isolated or account-home"); + }); + + it("removes the isolated execution root when a session expires (#5048)", async () => { + const rootsBefore = privateExecutionRoots(); + const session = await startLocalCredentialHelper({ + commandArgv: [process.execPath, "-e", "process.exit(0)"], + executionProfile: "isolated", + fields: [{ name: "OPENAI_API_KEY", type: "secret" }], + formBytes: fs.readFileSync(FORM_PATH), + timeoutMs: 20, + }); + + await expectCompletionCode(session.completion, 1); + expect(privateExecutionRoots()).toEqual(rootsBefore); + }); + + it("removes the isolated execution root when the approved executable cannot start (#5048)", async () => { + const rootsBefore = privateExecutionRoots(); + const missingExecutable = path.join( + path.parse(process.execPath).root, + "nemoclaw-definitely-missing-executable", + ); + const session = await startLocalCredentialHelper({ + commandArgv: [missingExecutable], + executionProfile: "isolated", + fields: [ + { name: "OPENAI_API_KEY", type: "secret" }, + { name: "PUBLIC_ID", type: "text" }, + ], + formBytes: fs.readFileSync(FORM_PATH), + }); + const formUrl = new URL(session.url); + const capability = new URLSearchParams(formUrl.hash.slice(1)).get("cap") ?? ""; + const accepted = await request(formUrl, { + body: validBody(), + headers: { + "content-type": "application/json", + origin: formUrl.origin, + "x-nemoclaw-capability": capability, + }, + method: "POST", + path: "/submit", + }); + + expectHttpStatus(accepted, 202); + await expectCompletionCode(session.completion, 1); + expect(privateExecutionRoots()).toEqual(rootsBefore); + }); + + it("rejects a modified credential form before listening (#5048)", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-credential-form-test-")); + tempDirs.add(dir); + const modifiedFormPath = path.join(dir, "local-credential-form.html"); + const modifiedForm = Buffer.concat([fs.readFileSync(FORM_PATH), Buffer.from("\n")]); + fs.writeFileSync(modifiedFormPath, modifiedForm); + const captured = captureChild( + helperArgs( + ["OPENAI_API_KEY:secret"], + [process.execPath, "-e", "process.exit(0)"], + modifiedFormPath, + ), + ); + + const result = await withTimeout(captured.closed, PROCESS_TIMEOUT_MS, "modified helper form"); + + expect(result.code).not.toBe(0); + expect(captured.output()).toContain("Local credential form SHA-256 mismatch"); + expect(captured.output()).not.toMatch(READINESS_URL_PATTERN); + }); + + it("serves only the exact form bytes with hardened non-CORS headers (#5048)", async () => { + const fixture = createCommandFixture(); + const helper = await startHelper(fixture.command); + const result = await request(helper.formUrl); + + expect(helper.formUrl.origin).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(helper.formUrl.searchParams.has("cap")).toBe(false); + expect(result.status).toBe(200); + expect(result.body).toContain("NemoClaw Local Credential Form"); + expect(Number(result.headers["content-length"])).toBe(Buffer.byteLength(result.body)); + expect(result.body).not.toContain(helper.capability); + expect(JSON.stringify(result.headers)).not.toContain(helper.capability); + expect(result.headers["content-type"]).toMatch(/^text\/html(?:;\s*charset=utf-8)?$/i); + expect(result.headers["content-security-policy"]).toContain("frame-ancestors 'none'"); + expect(result.headers["cache-control"]).toBe("no-store"); + expect(result.headers["x-content-type-options"]).toBe("nosniff"); + expect(result.headers["referrer-policy"]).toBe("no-referrer"); + expect(result.headers["cross-origin-resource-policy"]).toBe("same-origin"); + expect(result.headers["cross-origin-opener-policy"]).toBe("same-origin"); + expectNoCors(result.headers); + expect(commandRunCount(fixture.markerPath)).toBe(0); + + const modifiedTarget = await request(helper.formUrl, { + path: `${helper.formUrl.pathname}${helper.formUrl.search}&unexpected=1`, + }); + expectRejected(modifiedTarget); + + const stillAvailable = await request(helper.formUrl); + expect(stillAvailable.status).toBe(200); + expect(stillAvailable.body).toBe(result.body); + expect(commandRunCount(fixture.markerPath)).toBe(0); + }); + + it("strips inherited credentials and process controls before launching the approved command (#5048)", async () => { + const fixture = createCommandFixture(); + const hostileConfigRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hostile-config-")); + tempDirs.add(hostileConfigRoot); + for (const relativePath of [".curlrc", ".gitconfig", ".netrc", ".npmrc", ".zshenv"]) { + fs.writeFileSync(path.join(hostileConfigRoot, relativePath), "attack-marker\n"); + } + fs.mkdirSync(path.join(hostileConfigRoot, ".git")); + fs.writeFileSync( + path.join(hostileConfigRoot, ".git", "config"), + "[credential]\nhelper = attack\n", + ); + fs.mkdirSync(path.join(hostileConfigRoot, "pip")); + fs.writeFileSync( + path.join(hostileConfigRoot, "pip", "pip.conf"), + "[global]\nindex-url=attack\n", + ); + const helper = await startHelper( + fixture.command, + { + ...CONFIG_ROOT_ENV_OVERRIDES, + ...NETWORK_TRUST_ENV_OVERRIDES, + ...PACKAGE_CONTROL_ENV_OVERRIDES, + BASH_ENV: "ambient-shell-hook", + "BASH_FUNC_echo%%": '() { printf "%s" "$OPENAI_API_KEY"; }', + DOTNET_STARTUP_HOOKS: "ambient-startup-hook", + GIT_EXEC_PATH: "/ambient/git-core", + GIT_CONFIG: "ambient-git-config", + GH_TOKEN: "ambient-github-token", + Gh_ToKeN: "ambient-mixed-case-github-token", + GIT_CONFIG_COUNT: "1", + GIT_EXTERNAL_DIFF: "/ambient/diff-wrapper", + GIT_PROXY_COMMAND: "/ambient/proxy-wrapper", + GIT_TRACE2_EVENT: "/ambient/git-trace.json", + GIT_SSH: "/ambient/ssh-wrapper", + NODE_OPTIONS: "--no-warnings", + Node_Options: "--no-warnings", + NEMOCLAW_PROVIDER: "ambient-provider", + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "attacker.invalid/supervisor:latest", + OPENAI_API_KEY: "ambient-openai-key", + PATH: "/ambient/search/path", + Path: "/ambient/mixed-case/search/path", + PUBLIC_ID: "ambient-public-id", + UNRELATED_API_TOKEN: "ambient-generic-token", + HOME: hostileConfigRoot, + XDG_CONFIG_HOME: hostileConfigRoot, + }, + "isolated", + undefined, + hostileConfigRoot, + ); + + const accepted = await request(helper.formUrl, { + body: validBody(), + headers: validHeaders(helper), + method: "POST", + path: "/submit", + }); + + expect(accepted.status).toBe(202); + await expectSuccessfulCompletion(helper, fixture.markerPath); + }); + + it.runIf(process.platform !== "win32" && fs.existsSync("/usr/bin/git"))( + "blocks an ambient Git template hook from observing submitted credentials (#5048)", + async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-git-template-test-")); + tempDirs.add(dir); + const source = path.join(dir, "source"); + const destination = path.join(dir, "clone"); + const template = path.join(dir, "template"); + const hooks = path.join(template, "hooks"); + const leakPath = path.join(dir, "leaked-secret.txt"); + fs.mkdirSync(hooks, { recursive: true }); + + execFileSync("/usr/bin/git", ["init", "--quiet", source]); + fs.writeFileSync(path.join(source, "tracked.txt"), "fixture\n"); + execFileSync("/usr/bin/git", ["-C", source, "add", "tracked.txt"]); + execFileSync("/usr/bin/git", [ + "-C", + source, + "-c", + "user.name=NemoClaw Test", + "-c", + "user.email=nemoclaw-test@example.invalid", + "commit", + "--quiet", + "--no-gpg-sign", + "-m", + "fixture", + ]); + + const hookPath = path.join(hooks, "post-checkout"); + const quotedLeakPath = `'${leakPath.replaceAll("'", `'"'"'`)}'`; + fs.writeFileSync(hookPath, `#!/bin/sh\nprintf '%s' "$OPENAI_API_KEY" > ${quotedLeakPath}\n`); + fs.chmodSync(hookPath, 0o755); + + const helper = await startHelper(["/usr/bin/git", "clone", "--quiet", source, destination], { + GIT_TEMPLATE_DIR: template, + }); + const accepted = await request(helper.formUrl, { + body: validBody(), + headers: validHeaders(helper), + method: "POST", + path: "/submit", + }); + + expect(accepted.status).toBe(202); + await expect(helper.closed).resolves.toEqual({ code: 0, signal: null }); + expect(fs.readFileSync(path.join(destination, "tracked.txt"), "utf8")).toBe("fixture\n"); + expect(fs.existsSync(path.join(destination, ".git", "hooks", "post-checkout"))).toBe(false); + expect(fs.existsSync(leakPath)).toBe(false); + expect(helper.output()).not.toContain(TEST_SECRET); + }, + ); + + it("uses only the explicit cwd and OS account home in account-home mode (#5048)", async () => { + const commandCwd = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-account-cwd-test-")); + tempDirs.add(commandCwd); + const markerPath = path.join(commandCwd, "account-context.json"); + const command = [ + process.execPath, + "-e", + `require("node:fs").writeFileSync(process.argv[1], JSON.stringify({cwd:process.cwd(),environment:Object.fromEntries(${JSON.stringify(CONFIG_ROOT_ENV_NAMES)}.map((name)=>[name,process.env[name]]))}))`, + markerPath, + ]; + const helper = await startHelper( + command, + CONFIG_ROOT_ENV_OVERRIDES, + "account-home", + commandCwd, + ); + + const accepted = await request(helper.formUrl, { + body: validBody(), + headers: validHeaders(helper), + method: "POST", + path: "/submit", + }); + + expect(accepted.status).toBe(202); + await expectSuccessfulCompletion(helper, markerPath); + const observed = JSON.parse(fs.readFileSync(markerPath, "utf8")) as { + cwd: string; + environment: Record; + }; + const accountHome = os.userInfo().homedir; + const accountTemp = path.join(accountHome, "AppData", "Local", "Temp"); + const hasWindowsDrive = /^[A-Za-z]:[\\/]/.test(accountHome); + const expectedEnvironment: Record = + process.platform === "win32" + ? { + APPDATA: path.join(accountHome, "AppData", "Roaming"), + ...(hasWindowsDrive + ? { + HOMEDRIVE: accountHome.slice(0, 2), + HOMEPATH: accountHome.slice(2) || "\\", + } + : {}), + HOME: accountHome, + LOCALAPPDATA: path.join(accountHome, "AppData", "Local"), + PWD: commandCwd, + TEMP: accountTemp, + TMP: accountTemp, + TMPDIR: accountTemp, + USERPROFILE: accountHome, + } + : { HOME: accountHome, PWD: commandCwd }; + expect(observed.cwd).toBe(commandCwd); + expect( + Object.fromEntries(Object.entries(observed.environment).filter(([, value]) => value)), + ).toEqual(expectedEnvironment); + }); + + it("preserves explicit approved proxy, CA, and config arguments byte-for-byte (#5048)", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-credential-argv-test-")); + tempDirs.add(dir); + const markerPath = path.join(dir, "command-argv.json"); + const expectedArgv = [ + "--proxy", + "http://trusted-proxy.internal:3128", + "--noproxy", + "127.0.0.1", + "--cacert", + "/trusted/ca.pem", + "--config", + "/trusted/client.conf", + "--profile", + "approved-profile", + ]; + const command = [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)))', + markerPath, + ...expectedArgv, + ]; + const helper = await startHelper(command, NETWORK_TRUST_ENV_OVERRIDES); + + const accepted = await request(helper.formUrl, { + body: validBody(), + headers: validHeaders(helper), + method: "POST", + path: "/submit", + }); + + expect(accepted.status).toBe(202); + await expectSuccessfulCompletion(helper, markerPath); + expect(JSON.parse(fs.readFileSync(markerPath, "utf8"))).toEqual(expectedArgv); + }); + + it("blocks an inherited Bash function from intercepting the approved command (#5048)", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-credential-bash-test-")); + tempDirs.add(dir); + const markerPath = path.join(dir, "command-runs.txt"); + const attackMarkerPath = path.join(dir, "ambient-function-ran.txt"); + const command = [ + "/bin/bash", + "--noprofile", + "--norc", + "-c", + 'echo safe >/dev/null && test ! -e "$ATTACK_MARKER" && printf "ran\\n" > "$1"', + "bash", + markerPath, + ]; + const helper = await startHelper(command, { + ATTACK_MARKER: attackMarkerPath, + "BASH_FUNC_echo%%": '() { printf "%s" "$OPENAI_API_KEY" > "$ATTACK_MARKER"; }', + PATH: "/ambient/search/path", + }); + + const accepted = await request(helper.formUrl, { + body: validBody(), + headers: validHeaders(helper), + method: "POST", + path: "/submit", + }); + + expect(accepted.status).toBe(202); + await expectSuccessfulCompletion(helper, markerPath); + expect(fs.existsSync(attackMarkerPath)).toBe(false); + }); + + it("rejects Host, Origin, capability, and CORS probes without consuming the session (#5048)", async () => { + const fixture = createCommandFixture(); + const helper = await startHelper(fixture.command); + const body = validBody(); + const attacks: RequestOptions[] = [ + { + body, + headers: { ...validHeaders(helper), host: `localhost:${helper.formUrl.port}` }, + method: "POST", + path: "/submit", + }, + { + body, + headers: { ...validHeaders(helper), origin: `http://localhost:${helper.formUrl.port}` }, + method: "POST", + path: "/submit", + }, + { + body, + headers: { + ...validHeaders(helper), + "x-nemoclaw-capability": "x".repeat(43), + }, + method: "POST", + path: "/submit", + }, + { + body, + headers: { + "content-type": "application/json", + origin: helper.formUrl.origin, + }, + method: "POST", + path: "/submit", + }, + { + headers: { + "access-control-request-headers": "x-nemoclaw-capability, content-type", + "access-control-request-method": "POST", + origin: "https://attacker.invalid", + }, + method: "OPTIONS", + path: "/submit", + }, + ]; + + for (const attack of attacks) { + expectRejected(await request(helper.formUrl, attack)); + expect(commandRunCount(fixture.markerPath)).toBe(0); + } + + const accepted = await request(helper.formUrl, { + body, + headers: validHeaders(helper), + method: "POST", + path: "/submit", + }); + expect(accepted.status).toBe(202); + expectNoCors(accepted.headers); + await expectSuccessfulCompletion(helper, fixture.markerPath); + }); + + it("rejects media, encoding, body, and exact-schema violations without consuming the session (#5048)", async () => { + const fixture = createCommandFixture(); + const helper = await startHelper(fixture.command); + const authHeaders = validHeaders(helper); + const invalidRequests: RequestOptions[] = [ + { + body: validBody(), + headers: { ...authHeaders, "content-type": "text/plain" }, + method: "POST", + path: "/submit", + }, + { + body: validBody(), + headers: { ...authHeaders, "content-type": "application/json; charset=utf-8" }, + method: "POST", + path: "/submit", + }, + { + body: validBody(), + headers: { ...authHeaders, "content-encoding": "gzip" }, + method: "POST", + path: "/submit", + }, + { + body: validBody(), + headers: { ...authHeaders, "transfer-encoding": "chunked" }, + method: "POST", + omitContentLength: true, + path: "/submit", + }, + { + body: Buffer.alloc(65_537, 0x61), + headers: authHeaders, + method: "POST", + path: "/submit", + }, + { + body: "{not-json", + headers: authHeaders, + method: "POST", + path: "/submit", + }, + { + body: JSON.stringify({ values: { OPENAI_API_KEY: TEST_SECRET } }), + headers: authHeaders, + method: "POST", + path: "/submit", + }, + { + body: JSON.stringify({ + argv: ["sh", "-c", "unexpected"], + values: { OPENAI_API_KEY: TEST_SECRET, PUBLIC_ID: TEST_PUBLIC_VALUE }, + }), + headers: authHeaders, + method: "POST", + path: "/submit", + }, + { + body: JSON.stringify({ + values: { OPENAI_API_KEY: TEST_SECRET, PUBLIC_ID: 42 }, + }), + headers: authHeaders, + method: "POST", + path: "/submit", + }, + { + body: JSON.stringify({ + values: { OPENAI_API_KEY: TEST_SECRET, PUBLIC_ID: "é".repeat(8_193) }, + }), + headers: authHeaders, + method: "POST", + path: "/submit", + }, + ]; + + for (const invalid of invalidRequests) { + expectRejected(await request(helper.formUrl, invalid)); + expect(commandRunCount(fixture.markerPath)).toBe(0); + } + + const accepted = await request(helper.formUrl, { + body: validBody(), + headers: authHeaders, + method: "POST", + path: "/submit", + }); + expect(accepted.status).toBe(202); + await expectSuccessfulCompletion(helper, fixture.markerPath); + }); + + it("claims one racing submission, runs the command once, and closes the listener (#5048)", async () => { + const fixture = createCommandFixture(); + const helper = await startHelper(fixture.command); + const submission = () => + request(helper.formUrl, { + body: validBody(), + headers: validHeaders(helper), + method: "POST", + path: "/submit", + }); + + const outcomes = await Promise.allSettled([submission(), submission()]); + const responses = outcomes.flatMap((outcome) => + outcome.status === "fulfilled" ? [outcome.value] : [], + ); + const accepted = responses.filter((response) => response.status === 202); + + expect(accepted).toHaveLength(1); + expect(responses.filter((response) => response.status >= 200 && response.status < 300)).toEqual( + accepted, + ); + for (const response of responses.filter((response) => response.status !== 202)) { + expect(response.status).toBe(409); + expectNoCors(response.headers); + } + expect(accepted[0]?.headers.connection).toBe("close"); + expect(accepted[0]?.body).not.toContain(TEST_SECRET); + + await expectSuccessfulCompletion(helper, fixture.markerPath); + expect(helper.output()).not.toContain(TEST_SECRET); + expect(fs.existsSync(fixture.unexpectedShellPath)).toBe(false); + await expect(request(helper.formUrl)).rejects.toBeInstanceOf(Error); + }); + + it("executes once when the client sends a valid request and abandons the response (#5048)", async () => { + const fixture = createCommandFixture(); + const helper = await startHelper(fixture.command); + const body = validBody(); + const rawRequest = [ + "POST /submit HTTP/1.1", + `Host: ${helper.formUrl.host}`, + `Origin: ${helper.formUrl.origin}`, + `X-NemoClaw-Capability: ${helper.capability}`, + "Content-Type: application/json", + `Content-Length: ${Buffer.byteLength(body)}`, + "Connection: close", + "", + body, + ].join("\r\n"); + + await new Promise((resolve, reject) => { + const socket = net.createConnection( + { host: helper.formUrl.hostname, port: Number(helper.formUrl.port) }, + () => { + socket.end(rawRequest, () => { + socket.destroy(); + resolve(); + }); + }, + ); + socket.once("error", reject); + }); + + await expectSuccessfulCompletion(helper, fixture.markerPath); + expect(helper.output()).not.toContain(TEST_SECRET); + await expect(request(helper.formUrl)).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 31f8f4b618..8da752b2a9 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import fs from "node:fs"; import { createHash } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; @@ -25,15 +25,19 @@ const localCredentialFormSource = path.join( "resources", "local-credential-form.html", ); +const localCredentialHelperUrl = + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/dd61a307d7ddf7be99de8ff1e2678fb8ef42f8e6/scripts/local-credential-helper.mts"; +const localCredentialHelperSha256 = + "1a42bbe8dbc9003cb79d4e641b53760571aacd85293671aee97c09c0746fef33"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/c9aac7dc12bacdaa4d38af552b893021049ee836/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/dd61a307d7ddf7be99de8ff1e2678fb8ef42f8e6/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = - "cc746703ab514cf33d7131915f16e8dc19346b26a4d953c5125be81449d6e6f6"; // gitleaks:allow -- checked-in SHA-256 fixture + "5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ - "'sha256-7knX1kPQ", - "ir4x3z0uoR2GmEi9", - "hb0+82UEW2o9BzJD", - "520='", + "'sha256-i3cXmSMU", + "jTA5LqLSfFQpXe0B", + "BZRj4cM8t36dJMm3", + "YJw='", ].join(""); const localCredentialFormStyleCspHash = [ "'sha256-W4wSJyrm", @@ -41,6 +45,88 @@ const localCredentialFormStyleCspHash = [ "msaHh6dbUj9ZlKh", "xipME='", ].join(""); +const localCredentialCapability = "A".repeat(43); +const localCredentialNetworkControlNames = [ + "ALL_PROXY", + "AWS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "DENO_CERT", + "FTP_PROXY", + "GIT_PROXY_SSL_CAINFO", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", + "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", + "GRPC_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NO_PROXY", + "REQUESTS_CA_BUNDLE", + "SSLKEYLOGFILE", + "SSL_CERT_DIR", + "SSL_CERT_FILE", +]; +const localCredentialConfigControlNames = [ + "ALLUSERSPROFILE", + "APPDATA", + "CURL_HOME", + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "GCONV_PATH", + "GIT_COMMON_DIR", + "GIT_DIR", + "GLIBC_TUNABLES", + "HOME", + "HOMEDRIVE", + "HOMEPATH", + "KUBECONFIG", + "LOCALAPPDATA", + "LOCPATH", + "NETRC", + "NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL", + "NEMOCLAW_BOOTSTRAP_PAYLOAD", + "NEMOCLAW_INSTALL_REF", + "NEMOCLAW_INSTALL_TAG", + "NEMOCLAW_INSTALLER_STAGED", + "NEMOCLAW_INSTALLER_URL", + "NEMOCLAW_OPENSHELL_BIN", + "NEMOCLAW_OPENSHELL_CHANNEL", + "NEMOCLAW_OPENSHELL_GATEWAY_BIN", + "NEMOCLAW_OPENSHELL_SANDBOX_BIN", + "NEMOCLAW_REPO_ROOT", + "NEMOCLAW_SOURCE_ROOT", + "NVM_DIR", + "OLDPWD", + "OPENSSL_CONF", + "OPENSSL_CONF_INCLUDE", + "OPENSSL_ENGINES", + "OPENSSL_MODULES", + "PROGRAMDATA", + "PSMODULEPATH", + "PWD", + "PYTHONUSERBASE", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "VIRTUAL_ENV", + "XDG_BIN_HOME", + "XDG_CACHE_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", + "ZDOTDIR", +]; const starterPromptPages = [ "docs/index.mdx", "docs/get-started/quickstart.mdx", @@ -57,6 +143,12 @@ function urlsIn(content: string): URL[] { return Array.from(content.matchAll(/https?:\/\/[^\s"'<>;]+/g), ([match]) => new URL(match)); } +function withCredentialCapability(url: string, capability = localCredentialCapability): string { + const parsed = new URL(url); + parsed.hash = `cap=${capability}`; + return parsed.href; +} + function fail(message: string): never { throw new Error(message); } @@ -104,8 +196,10 @@ class FakeElement { autocomplete = ""; className = ""; disabled = false; + hidden = false; id = ""; name = ""; + readOnly = false; required = false; spellcheck = true; textContent = ""; @@ -137,11 +231,12 @@ class FakeElement { querySelectorAll(selector: string): FakeElement[] { const result: FakeElement[] = []; const visit = (element: FakeElement) => { + const matchesInput = selector === "input" && element.tagName === "input"; const matchesSecretInput = selector === "input[data-secret='true']" && element.tagName === "input" && element.dataset.secret === "true"; - matchesSecretInput && result.push(element); + (matchesInput || matchesSecretInput) && result.push(element); for (const child of element.children) { visit(child); } @@ -164,6 +259,8 @@ class FakeDocument { ["credential-form", "form"], ["result", "section"], ["submit-button", "button"], + ["edit-button", "button"], + ["confirm-button", "button"], ["origin-notice", "div"], ] as const) { const element = new FakeElement(tagName); @@ -173,6 +270,8 @@ class FakeDocument { this.getElementById("credential-form").append( this.getElementById("fields"), this.getElementById("submit-button"), + this.getElementById("edit-button"), + this.getElementById("confirm-button"), ); } @@ -205,26 +304,46 @@ class FakeFormData { } } -function runCredentialForm(url: string, fetchImpl = async () => ({ ok: true, status: 200 })) { +function runCredentialForm( + url: string, + fetchImpl: ( + target: string, + init?: unknown, + ) => Promise<{ ok: boolean; status: number }> = async () => ({ ok: true, status: 202 }), +) { const formSource = fs.readFileSync(localCredentialFormSource, "utf8"); const script = extractTagContent(formSource, "script"); const parsedUrl = new URL(url); const document = new FakeDocument(); + const consoleCalls: unknown[][] = []; const fetchCalls: Array<{ url: string; init?: unknown }> = []; + const historyCalls: string[] = []; const context = { - console: { error: () => undefined }, + console: { + error: (...args: unknown[]) => consoleCalls.push(args), + log: (...args: unknown[]) => consoleCalls.push(args), + warn: (...args: unknown[]) => consoleCalls.push(args), + }, document, Error, fetch: async (target: string, init?: unknown) => { fetchCalls.push({ url: target, init }); - return fetchImpl(); + return fetchImpl(target, init); }, FormData: FakeFormData, + TextEncoder, URLSearchParams, window: { + history: { + replaceState: (_state: null, _title: string, target: string) => { + historyCalls.push(target); + }, + }, location: { + hash: parsedUrl.hash, hostname: parsedUrl.hostname, href: parsedUrl.href, + pathname: parsedUrl.pathname, search: parsedUrl.search, }, }, @@ -232,12 +351,28 @@ function runCredentialForm(url: string, fetchImpl = async () => ({ ok: true, sta vm.runInNewContext(script, context); const form = document.getElementById("credential-form"); + const click = async (id: string) => { + const listener = + document.getElementById(id).listeners.get("click") ?? + fail(`Missing click listener for ${id}`); + await listener({ preventDefault: () => undefined }); + }; return { + confirm: () => click("confirm-button"), + confirmButton: document.getElementById("confirm-button"), + consoleCalls, document, + edit: () => click("edit-button"), + editButton: document.getElementById("edit-button"), fetchCalls, fieldsElement: document.getElementById("fields"), form, + historyCalls, originNotice: document.getElementById("origin-notice"), + preview: async () => { + const listener = form.listeners.get("submit") ?? fail("Missing submit listener"); + await listener({ preventDefault: () => undefined }); + }, resultElement: document.getElementById("result"), submit: async () => { const listener = form.listeners.get("submit") ?? fail("Missing submit listener"); @@ -280,19 +415,38 @@ describe("starter prompt docs CTA", () => { ); }); - it("pins local credential capture to the checked-in form template (#5048)", () => { + it("pins local credential capture to the checked-in helper and form (#5048)", () => { const promptSource = fs.readFileSync(starterPromptSource, "utf8"); const formSource = fs.readFileSync(localCredentialFormSource, "utf8"); + expect(promptSource).toContain(localCredentialHelperUrl); + expect(promptSource).toContain(localCredentialHelperSha256); expect(promptSource).toContain(localCredentialFormUrl); expect(promptSource).toContain(localCredentialFormSha256); expect(createHash("sha256").update(formSource).digest("hex")).toBe(localCredentialFormSha256); + expect(localCredentialHelperUrl).toMatch(/\/[0-9a-f]{40}\//); + expect(localCredentialHelperUrl).not.toMatch(/\/(?:main|master)\//); expect(localCredentialFormUrl).toMatch(/\/[0-9a-f]{40}\//); expect(localCredentialFormUrl).not.toMatch(/\/(?:main|master)\//); - expect(promptSource).toContain("Do not generate, rewrite, or redesign credential-form HTML."); - expect(promptSource).toContain("immutable URL and digest as one reviewed trust boundary"); - expect(promptSource).toContain("serve it from a helper bound to \\`127.0.0.1\\`"); - expect(promptSource).toContain("?fields=NVIDIA_INFERENCE_API_KEY:secret"); + expect(promptSource).toContain("Do not generate, rewrite, or redesign the helper or form."); + expect(promptSource).toContain( + "two immutable URL and digest pairs as one reviewed trust boundary", + ); + expect(promptSource).toContain("exact environment-variable names and exact command argv"); + expect(promptSource).toContain("--field NAME:type"); + expect(promptSource).toContain("--execution-profile isolated"); + expect(promptSource).toContain("--execution-profile account-home --cwd"); + expect(promptSource).toContain("Never put credentials in argv"); + expect(promptSource).toContain("Confirm and Run Approved Command"); + expect(promptSource).toContain("do not retry or resubmit"); + expect(promptSource).toContain("exposure minimization, not guaranteed erasure"); + expect(promptSource).toContain("prefer letting that command prompt for the credential itself"); + expect(promptSource).toContain("Do not hand-assemble a \\`curl | bash\\` wrapper"); + // The slim prompt delegates install-time credential mechanics to the helper and installer; + // guard against the prose curl | bash wrapper synthesis creeping back into the copied prompt. + expect(promptSource).not.toContain(" -c"); + expect(promptSource).not.toContain("non-exported shell variable"); + expect(promptSource).not.toContain("unsets the exported credential before starting"); expect(formSource).toContain("NemoClaw Local Credential Form"); expect(formSource).toContain("Content-Security-Policy"); expect(formSource).toContain("connect-src 'self';"); @@ -306,7 +460,6 @@ describe("starter prompt docs CTA", () => { `script-src ${sha256Source(extractTagContent(formSource, "script"))};`, ); expect(cspMetaContent(formSource)).not.toContain("frame-ancestors"); - expect(promptSource).toContain("Content-Security-Policy: frame-ancestors 'none'"); expect(formSource).toContain('const LOCAL_SUBMIT_PATH = "/submit";'); expect(formSource).toContain("fetch(LOCAL_SUBMIT_PATH"); expect(formSource).not.toContain('params.get("submit")'); @@ -317,25 +470,31 @@ describe("starter prompt docs CTA", () => { expect(formSource).not.toContain("sessionStorage"); }); - it("warns and disables submit when credential fields are missing or invalid (#5048)", async () => { - const missing = runCredentialForm("http://127.0.0.1:4123/local-credential-form.html"); + it("rejects missing, ambiguous, and unsafe credential schemas (#5048)", async () => { + const missing = runCredentialForm( + withCredentialCapability("http://127.0.0.1:4123/local-credential-form.html"), + ); expect(missing.submitButton.disabled).toBe(true); expect(missing.fieldsElement.children).toHaveLength(0); expect(missing.resultElement.allText()).toContain("Credential fields are not configured."); const invalid = runCredentialForm( - "http://127.0.0.1:4123/local-credential-form.html?fields=bad-name:secret,VALID_NAME:text", + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=bad-name:secret,VALID_NAME:text", + ), ); expect(invalid.submitButton.disabled).toBe(true); expect(invalid.fieldsElement.children.map((child) => child.textContent)).toContain( "Valid Name", ); expect(invalid.resultElement.allText()).toContain("Rejected specs: bad-name:secret"); - await invalid.submit(); + await invalid.preview(); expect(invalid.fetchCalls).toHaveLength(0); const allInvalid = runCredentialForm( - "http://127.0.0.1:4123/local-credential-form.html?fields=bad-name:secret", + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=bad-name:secret", + ), ); expect(allInvalid.submitButton.disabled).toBe(true); expect(allInvalid.fieldsElement.children).toHaveLength(0); @@ -349,18 +508,92 @@ describe("starter prompt docs CTA", () => { "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret,", "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret&fields=PUBLIC_ID:text", "http://127.0.0.1:4123/local-credential-form.html?field=SECRET_TOKEN:secret&fields=PUBLIC_ID:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=NVIDIA_INFERENCE_API_KEY:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=WEBHOOK_URL:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=PRIVATE:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=PIN:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=NODE_OPTIONS:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=BASH_FUNC_ECHO:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=DOTNET_STARTUP_HOOKS:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=GIT_EXEC_PATH:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=GIT_EXTERNAL_DIFF:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=GIT_PROXY_COMMAND:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=GIT_TRACE2_EVENT:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=GIT_SSH:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=NPM_CONFIG_USERCONFIG:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=LD_PRELOAD:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=DYLD_INSERT_LIBRARIES:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=GIT_CONFIG:secret", + "http://127.0.0.1:4123/local-credential-form.html?fields=GIT_CONFIG_COUNT:secret", + ...localCredentialNetworkControlNames.map( + (name) => `http://127.0.0.1:4123/local-credential-form.html?fields=${name}:text`, + ), + ...localCredentialConfigControlNames.map( + (name) => `http://127.0.0.1:4123/local-credential-form.html?fields=${name}:text`, + ), + "http://127.0.0.1:4123/local-credential-form.html?fields=NPM_CONFIG_REGISTRY:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=OPENSHELL_DOCKER_SUPERVISOR_IMAGE:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=PIP_INDEX_URL:text", + "http://127.0.0.1:4123/local-credential-form.html?fields=PUBLIC_ID:text&submit=/capture", ]) { - const malformed = runCredentialForm(malformedUrl); + const malformed = runCredentialForm(withCredentialCapability(malformedUrl)); expect(malformed.submitButton.disabled, malformedUrl).toBe(true); expect(malformed.resultElement.allText(), malformedUrl).toContain("rejected"); - await malformed.submit(); + await malformed.preview(); expect(malformed.fetchCalls, malformedUrl).toHaveLength(0); } + + const tooManyFields = Array.from({ length: 17 }, (_, index) => `PUBLIC_ID_${index}:text`); + const oversizedSchema = runCredentialForm( + withCredentialCapability( + `http://127.0.0.1:4123/local-credential-form.html?fields=${tooManyFields.join(",")}`, + ), + ); + expect(oversizedSchema.submitButton.disabled).toBe(true); + expect(oversizedSchema.resultElement.allText()).toContain("too many fields"); + await oversizedSchema.preview(); + expect(oversizedSchema.fetchCalls).toHaveLength(0); + }); + + it("requires and consumes one fragment capability before enabling preview (#5048)", () => { + const withoutCapability = runCredentialForm( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret", + ); + expect(withoutCapability.submitButton.disabled).toBe(true); + expect(withoutCapability.resultElement.allText()).toContain( + "missing a valid one-time capability", + ); + expect(withoutCapability.historyCalls).toEqual([ + "/local-credential-form.html?fields=SECRET_TOKEN:secret", + ]); + + const malformedCapability = runCredentialForm( + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret", + "too-short", + ), + ); + expect(malformedCapability.submitButton.disabled).toBe(true); + expect(malformedCapability.resultElement.allText()).toContain( + "missing a valid one-time capability", + ); + + const validCapability = runCredentialForm( + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret", + ), + ); + expect(validCapability.submitButton.disabled).toBe(false); + expect(validCapability.historyCalls).toEqual([ + "/local-credential-form.html?fields=SECRET_TOKEN:secret", + ]); }); - it("submits only to the loopback helper and redacts secret values (#5048)", async () => { + it("previews locally then confirms one frozen, authenticated payload (#5048)", async () => { const repeated = runCredentialForm( - "http://127.0.0.1:4123/local-credential-form.html?field=SECRET_TOKEN:secret&field=PUBLIC_ID:text", + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?field=SECRET_TOKEN:secret&field=PUBLIC_ID:text", + ), ); const repeatedInputs = repeated.fieldsElement.children.filter( (child) => child.tagName === "input", @@ -372,7 +605,9 @@ describe("starter prompt docs CTA", () => { expect(repeated.submitButton.disabled).toBe(false); const rendered = runCredentialForm( - "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret,PUBLIC_ID:text&submit=http://127.0.0.1:9/capture", + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret,PUBLIC_ID:text", + ), ); const inputs = rendered.fieldsElement.children.filter((child) => child.tagName === "input"); const secretInput = inputs.find((input) => input.name === "SECRET_TOKEN"); @@ -382,35 +617,140 @@ describe("starter prompt docs CTA", () => { secretInput!.value = "super-secret"; textInput!.value = "public-id"; - await rendered.submit(); + await rendered.preview(); + + expect(rendered.fetchCalls).toHaveLength(0); + expect(secretInput?.readOnly).toBe(true); + expect(textInput?.readOnly).toBe(true); + expect(secretInput?.value).toBe(""); + expect(textInput?.value).toBe(""); + expect(rendered.submitButton.hidden).toBe(true); + expect(rendered.editButton.hidden).toBe(false); + expect(rendered.confirmButton.hidden).toBe(false); + expect(rendered.resultElement.allText()).toContain("SECRET_TOKEN=********"); + expect(rendered.resultElement.allText()).toContain("PUBLIC_ID=public-id"); + expect(rendered.resultElement.allText()).not.toContain("super-secret"); + + secretInput!.value = "changed-after-preview"; + textInput!.value = "changed-public-id"; + await rendered.confirm(); expect(rendered.fetchCalls).toHaveLength(1); expect(rendered.fetchCalls[0]?.url).toBe("/submit"); + const request = rendered.fetchCalls[0]?.init as { + body: string; + cache: string; + credentials: string; + headers: Record; + method: string; + redirect: string; + }; + expect(request.method).toBe("POST"); + expect(request.cache).toBe("no-store"); + expect(request.credentials).toBe("omit"); + expect(request.redirect).toBe("error"); + expect(request.headers).toEqual({ + "Content-Type": "application/json", + "X-NemoClaw-Capability": localCredentialCapability, + }); + expect(JSON.parse(request.body)).toEqual({ + values: { PUBLIC_ID: "public-id", SECRET_TOKEN: "super-secret" }, + }); expect(secretInput?.value).toBe(""); - expect(textInput?.value).toBe("public-id"); + expect(textInput?.value).toBe(""); expect(rendered.resultElement.allText()).toContain("SECRET_TOKEN=********"); expect(rendered.resultElement.allText()).toContain("PUBLIC_ID=public-id"); expect(rendered.resultElement.allText()).not.toContain("super-secret"); + expect(rendered.submitButton.disabled).toBe(true); + expect(rendered.confirmButton.disabled).toBe(true); + await rendered.confirm(); + expect(rendered.fetchCalls).toHaveLength(1); }); - it("disables submit outside loopback and shows helper-friendly failures (#5048)", async () => { + it("discards a preview before accepting edited values (#5048)", async () => { + const rendered = runCredentialForm( + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret,PUBLIC_ID:text", + ), + ); + const inputs = rendered.fieldsElement.children.filter((child) => child.tagName === "input"); + const secretInput = inputs.find((input) => input.name === "SECRET_TOKEN")!; + const textInput = inputs.find((input) => input.name === "PUBLIC_ID")!; + secretInput.value = "first-secret"; + textInput.value = "first-id"; + + await rendered.preview(); + await rendered.edit(); + expect(rendered.fetchCalls).toHaveLength(0); + expect(secretInput.readOnly).toBe(false); + expect(textInput.readOnly).toBe(false); + expect(secretInput.value).toBe(""); + expect(textInput.value).toBe(""); + expect(rendered.submitButton.hidden).toBe(false); + + secretInput.value = "second-secret"; + textInput.value = "second-id"; + await rendered.preview(); + await rendered.confirm(); + const request = rendered.fetchCalls[0]?.init as { body: string }; + expect(JSON.parse(request.body)).toEqual({ + values: { PUBLIC_ID: "second-id", SECRET_TOKEN: "second-secret" }, + }); + }); + + it("disables non-loopback sessions and permanently locks ambiguous outcomes (#5048)", async () => { const nonLoopback = runCredentialForm( - "https://example.com/local-credential-form.html?fields=SECRET_TOKEN:secret", + withCredentialCapability( + "https://example.com/local-credential-form.html?fields=SECRET_TOKEN:secret", + ), ); expect(nonLoopback.submitButton.disabled).toBe(true); expect(nonLoopback.originNotice.classList.has("warning")).toBe(true); - await nonLoopback.submit(); + await nonLoopback.preview(); expect(nonLoopback.submitButton.disabled).toBe(true); expect(nonLoopback.fetchCalls).toHaveLength(0); const helperFailure = runCredentialForm( - "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret", + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret", + ), async () => ({ ok: false, status: 500 }), ); - await helperFailure.submit(); - expect(helperFailure.resultElement.allText()).toContain( - "Ask your coding agent to check the local helper and reopen the credential form.", + const failureInput = helperFailure.fieldsElement.children.find( + (child) => child.tagName === "input", + )!; + failureInput.value = "never-log-this"; + await helperFailure.preview(); + await helperFailure.confirm(); + expect(helperFailure.fetchCalls).toHaveLength(1); + expect(helperFailure.resultElement.allText()).toContain("outcome is unknown"); + expect(helperFailure.resultElement.allText()).toContain("Do not retry or resubmit"); + expect(helperFailure.resultElement.allText()).not.toContain("never-log-this"); + expect(failureInput.value).toBe(""); + expect(helperFailure.submitButton.disabled).toBe(true); + expect(helperFailure.confirmButton.disabled).toBe(true); + expect(helperFailure.consoleCalls).toHaveLength(0); + await helperFailure.preview(); + await helperFailure.confirm(); + expect(helperFailure.fetchCalls).toHaveLength(1); + + const networkFailure = runCredentialForm( + withCredentialCapability( + "http://127.0.0.1:4123/local-credential-form.html?fields=SECRET_TOKEN:secret", + ), + async () => { + throw new Error("response lost after acceptance"); + }, ); + const networkInput = networkFailure.fieldsElement.children.find( + (child) => child.tagName === "input", + )!; + networkInput.value = "also-never-log-this"; + await networkFailure.preview(); + await networkFailure.confirm(); + expect(networkFailure.resultElement.allText()).toContain("outcome is unknown"); + expect(networkFailure.consoleCalls).toHaveLength(0); + expect(networkInput.value).toBe(""); }); it("keeps Deep Agents as a selectable starter prompt option (#5048)", () => {