From 6546c085a76f1001ac89a5e47b8179067a3f617a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 19:58:49 -0700 Subject: [PATCH 01/20] fix(security): add one-shot credential helper Signed-off-by: Carlos Villela --- docs/resources/local-credential-form.html | 400 +++++++++--- package.json | 1 + scripts/local-credential-helper.mts | 714 ++++++++++++++++++++++ test/local-credential-helper.test.ts | 561 +++++++++++++++++ test/starter-prompt-docs.test.ts | 274 ++++++++- 5 files changed, 1826 insertions(+), 124 deletions(-) create mode 100755 scripts/local-credential-helper.mts create mode 100644 test/local-credential-helper.test.ts diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index e4998091dc..a3dd694f48 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,113 @@

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/local-credential-helper.mts b/scripts/local-credential-helper.mts new file mode 100755 index 0000000000..e901f9acbd --- /dev/null +++ b/scripts/local-credential-helper.mts @@ -0,0 +1,714 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256 = + "b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68"; // gitleaks:allow -- checked-in SHA-256 integrity pin + +export const LOCAL_CREDENTIAL_HELPER_HOST = "127.0.0.1"; +export const LOCAL_CREDENTIAL_FORM_PATH = "/local-credential-form.html"; +export const LOCAL_CREDENTIAL_SUBMIT_PATH = "/submit"; +export const LOCAL_CREDENTIAL_CAPABILITY_HEADER = "x-nemoclaw-capability"; + +const MAX_BODY_BYTES = 64 * 1024; +const MAX_FORM_BYTES = 1024 * 1024; +const MAX_FIELD_COUNT = 16; +const MAX_FIELD_VALUE_BYTES = 16 * 1024; +const MAX_HEADER_BYTES = 8 * 1024; +const SESSION_TIMEOUT_MS = 10 * 60 * 1000; +const FIELD_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,80}$/; +const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const FINAL_FORM_SHA256_PATTERN = /^[a-f0-9]{64}$/; + +// Keep this shape aligned with src/lib/security/credential-env.ts. This helper +// must remain standalone so a coding agent can run an integrity-checked copy +// before NemoClaw is installed. +export const CREDENTIAL_SHAPED_NAME_PATTERN = + /(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/i; + +const FORBIDDEN_CHILD_ENV_NAMES = new Set([ + "BASHOPTS", + "BASH_ENV", + "CDPATH", + "CLASSPATH", + "COMSPEC", + "ENV", + "GIT_ASKPASS", + "GIT_SSH_COMMAND", + "GLOBIGNORE", + "IFS", + "JAVA_TOOL_OPTIONS", + "JDK_JAVA_OPTIONS", + "NODE_EXTRA_CA_CERTS", + "NODE_OPTIONS", + "NODE_PATH", + "NPM_CONFIG_GLOBALCONFIG", + "NPM_CONFIG_NODE_OPTIONS", + "NPM_CONFIG_SCRIPT_SHELL", + "NPM_CONFIG_USERCONFIG", + "PATH", + "PATHEXT", + "PERL5LIB", + "PERL5OPT", + "PS4", + "PYTHONHOME", + "PYTHONINSPECT", + "PYTHONPATH", + "PYTHONSTARTUP", + "RUBYLIB", + "RUBYOPT", + "SHELL", + "SHELLOPTS", + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "_JAVA_OPTIONS", +]); + +export type CredentialFieldType = "secret" | "text"; + +export type CredentialField = Readonly<{ + name: string; + type: CredentialFieldType; +}>; + +export type LocalCredentialHelperCliOptions = Readonly<{ + commandArgv: readonly string[]; + fields: readonly CredentialField[]; + formPath: string; +}>; + +export type LocalCredentialHelperSession = Readonly<{ + completion: Promise; + origin: string; + server: Server; + url: string; +}>; + +type SessionState = "pending" | "claimed" | "expired" | "closed"; + +class RequestError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "RequestError"; + this.status = status; + } +} + +function defaultFormPath(): string { + return path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "docs", + "resources", + "local-credential-form.html", + ); +} + +export function isCredentialShapedName(name: string): boolean { + return CREDENTIAL_SHAPED_NAME_PATTERN.test(name); +} + +export function isForbiddenChildEnvName(name: string): boolean { + return ( + FORBIDDEN_CHILD_ENV_NAMES.has(name) || + name.startsWith("LD_") || + name.startsWith("DYLD_") || + name === "GIT_CONFIG" || + name.startsWith("GIT_CONFIG_") + ); +} + +export function parseCredentialField(spec: string): CredentialField { + const parts = spec.split(":"); + if (parts.length !== 2) { + throw new Error(`--field must use NAME:secret or NAME:text (received ${JSON.stringify(spec)})`); + } + + const [name, rawType] = parts; + if (!FIELD_NAME_PATTERN.test(name)) { + throw new Error( + `--field name must be an uppercase environment variable name: ${name || ""}`, + ); + } + if (rawType !== "secret" && rawType !== "text") { + throw new Error(`--field type must be secret or text for ${name}`); + } + if (isForbiddenChildEnvName(name)) { + throw new Error(`--field ${name} is a process-control environment variable and is not allowed`); + } + if (rawType === "text" && isCredentialShapedName(name)) { + throw new Error(`--field ${name} looks credential-shaped and must use :secret`); + } + return Object.freeze({ name, type: rawType }); +} + +export function parseCliArguments(argv: readonly string[]): LocalCredentialHelperCliOptions { + const separator = argv.indexOf("--"); + if (separator < 0) { + throw new Error("A literal -- separator followed by the approved command is required"); + } + + const optionArgs = argv.slice(0, separator); + const commandArgv = argv.slice(separator + 1); + if (commandArgv.length === 0 || commandArgv[0].length === 0) { + throw new Error("An executable must follow the -- separator"); + } + if (commandArgv.some((value) => value.includes("\0"))) { + throw new Error("Command arguments must not contain NUL bytes"); + } + + let formPath = defaultFormPath(); + let formPathSeen = false; + const fields: CredentialField[] = []; + const fieldNames = new Set(); + + for (let index = 0; index < optionArgs.length; index += 1) { + const option = optionArgs[index]; + if (option !== "--form" && option !== "--field") { + throw new Error(`Unknown option before --: ${option}`); + } + const value = optionArgs[index + 1]; + if (value === undefined || value === "--form" || value === "--field") { + throw new Error(`${option} requires a value`); + } + index += 1; + + if (option === "--form") { + if (formPathSeen) throw new Error("--form may be specified only once"); + if (value.length === 0 || value.includes("\0")) { + throw new Error("--form must be a non-empty path without NUL bytes"); + } + formPath = path.resolve(value); + formPathSeen = true; + continue; + } + + const field = parseCredentialField(value); + if (fieldNames.has(field.name)) { + throw new Error(`Duplicate --field name: ${field.name}`); + } + fieldNames.add(field.name); + fields.push(field); + if (fields.length > MAX_FIELD_COUNT) { + throw new Error(`At most ${MAX_FIELD_COUNT} credential fields are allowed`); + } + } + + if (fields.length === 0) { + throw new Error("At least one --field NAME:secret or --field NAME:text is required"); + } + + return Object.freeze({ + commandArgv: Object.freeze([...commandArgv]), + fields: Object.freeze([...fields]), + formPath, + }); +} + +export function loadVerifiedCredentialForm( + formPath: string, + expectedSha256: string = EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256, +): Buffer { + if (!FINAL_FORM_SHA256_PATTERN.test(expectedSha256)) { + throw new Error( + "Local credential form SHA-256 is not finalized in scripts/local-credential-helper.mts", + ); + } + const stat = statSync(formPath); + if (!stat.isFile()) throw new Error(`Local credential form is not a regular file: ${formPath}`); + if (stat.size <= 0 || stat.size > MAX_FORM_BYTES) { + throw new Error(`Local credential form must be between 1 and ${MAX_FORM_BYTES} bytes`); + } + const bytes = readFileSync(formPath); + const actualSha256 = createHash("sha256").update(bytes).digest("hex"); + if (!timingSafeStringEqual(actualSha256, expectedSha256)) { + throw new Error(`Local credential form SHA-256 mismatch: ${formPath}`); + } + return bytes; +} + +function timingSafeStringEqual(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes); +} + +function rawHeaderValues(request: IncomingMessage, headerName: string): string[] { + const values: string[] = []; + for (let index = 0; index < request.rawHeaders.length; index += 2) { + if (request.rawHeaders[index]?.toLowerCase() === headerName.toLowerCase()) { + values.push(request.rawHeaders[index + 1] ?? ""); + } + } + return values; +} + +function requireSingleHeader(request: IncomingMessage, headerName: string): string { + const values = rawHeaderValues(request, headerName); + if (values.length !== 1) { + throw new RequestError(400, `${headerName} header must appear exactly once`); + } + return values[0]; +} + +function capabilityMatches(request: IncomingMessage, expectedCapability: Buffer): boolean { + const values = rawHeaderValues(request, LOCAL_CREDENTIAL_CAPABILITY_HEADER); + if (values.length !== 1 || !CAPABILITY_PATTERN.test(values[0])) return false; + const received = Buffer.from(values[0], "base64url"); + return ( + received.length === expectedCapability.length && timingSafeEqual(received, expectedCapability) + ); +} + +function addCommonResponseHeaders(response: ServerResponse): void { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); +} + +function sendJson(response: ServerResponse, status: number, body: unknown): void { + addCommonResponseHeaders(response); + const encoded = Buffer.from(JSON.stringify(body)); + response.writeHead(status, { + Connection: "close", + "Content-Length": encoded.length, + "Content-Type": "application/json; charset=utf-8", + }); + response.end(encoded); +} + +function sendRequestError(response: ServerResponse, error: unknown): void { + if (response.headersSent) { + response.end(); + return; + } + const status = error instanceof RequestError ? error.status : 500; + const message = error instanceof RequestError ? error.message : "Local helper request failed"; + sendJson(response, status, { error: message }); +} + +function readBoundedBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + + request.on("data", (chunk: Buffer | string) => { + if (settled) return; + const bytes = Buffer.from(chunk); + total += bytes.length; + if (total > MAX_BODY_BYTES) { + settled = true; + scrubBuffers(chunks); + bytes.fill(0); + request.resume(); + reject(new RequestError(413, "Request body is too large")); + return; + } + chunks.push(bytes); + }); + request.on("end", () => { + if (settled) return; + settled = true; + const body = Buffer.concat(chunks, total); + scrubBuffers(chunks); + resolve(body); + }); + request.on("error", (error) => { + if (settled) return; + settled = true; + scrubBuffers(chunks); + reject(error); + }); + request.on("aborted", () => { + if (settled) return; + settled = true; + scrubBuffers(chunks); + reject(new RequestError(400, "Request body was aborted")); + }); + }); +} + +function scrubBuffers(buffers: Buffer[]): void { + for (const buffer of buffers) buffer.fill(0); + buffers.length = 0; +} + +function parseSubmittedValues( + body: Buffer, + fields: readonly CredentialField[], +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(body.toString("utf8")); + } catch { + throw new RequestError(400, "Request body must be valid JSON"); + } + if (!isRecord(parsed) || Object.keys(parsed).length !== 1 || !("values" in parsed)) { + throw new RequestError(400, "Request body must contain only values"); + } + const submitted = parsed.values; + if (!isRecord(submitted)) { + throw new RequestError(400, "values must be a JSON object"); + } + + const expectedNames = fields.map((field) => field.name).sort(); + const submittedNames = Object.keys(submitted).sort(); + if ( + expectedNames.length !== submittedNames.length || + expectedNames.some((name, index) => submittedNames[index] !== name) + ) { + throw new RequestError(400, "Submitted field names do not match the configured schema"); + } + + const values: Record = Object.create(null) as Record; + for (const field of fields) { + const value = submitted[field.name]; + if (typeof value !== "string" || value.length === 0) { + throw new RequestError(400, `Submitted value for ${field.name} must be a non-empty string`); + } + if (value.includes("\0")) { + throw new RequestError(400, `Submitted value for ${field.name} must not contain NUL bytes`); + } + if (Buffer.byteLength(value) > MAX_FIELD_VALUE_BYTES) { + throw new RequestError(413, `Submitted value for ${field.name} is too large`); + } + values[field.name] = value; + } + return values; +} + +export function buildCredentialFormCsp(formBytes: Buffer): string { + const source = formBytes.toString("utf8"); + const script = extractSingleInlineTag(source, "script"); + const style = extractSingleInlineTag(source, "style"); + const scriptHash = createHash("sha256").update(script).digest("base64"); + const styleHash = createHash("sha256").update(style).digest("base64"); + return [ + "default-src 'none'", + "base-uri 'none'", + "form-action 'self'", + `script-src 'sha256-${scriptHash}'`, + `style-src 'sha256-${styleHash}'`, + "connect-src 'self'", + "frame-ancestors 'none'", + ].join("; "); +} + +function extractSingleInlineTag(source: string, tagName: "script" | "style"): string { + const matches = [...source.matchAll(new RegExp(`<${tagName}>([\\s\\S]*?)`, "g"))]; + if (matches.length !== 1 || matches[0][1] === undefined) { + throw new Error(`Local credential form must contain exactly one inline <${tagName}> block`); + } + return matches[0][1]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function buildFormRequestTarget(fields: readonly CredentialField[]): string { + const params = new URLSearchParams(); + for (const field of fields) params.append("field", `${field.name}:${field.type}`); + return `${LOCAL_CREDENTIAL_FORM_PATH}?${params.toString()}`; +} + +function stopAcceptingConnections(server: Server): void { + try { + server.close(); + } catch { + // Best-effort shutdown only. + } +} + +function destroySockets(sockets: ReadonlySet, except?: Socket): void { + for (const socket of sockets) { + if (socket !== except) socket.destroy(); + } +} + +function scrubValues(values: Record, fields: readonly CredentialField[]): void { + for (const field of fields) { + if (Object.hasOwn(values, field.name)) values[field.name] = ""; + delete values[field.name]; + } +} + +export async function startLocalCredentialHelper(options: { + commandArgv: readonly string[]; + fields: readonly CredentialField[]; + formBytes: Buffer; + timeoutMs?: number; +}): Promise { + if (options.fields.length === 0 || options.fields.length > MAX_FIELD_COUNT) { + throw new Error(`Local credential helper requires between 1 and ${MAX_FIELD_COUNT} fields`); + } + const fieldNames = new Set(); + const fields = Object.freeze( + options.fields.map((field) => { + const validated = parseCredentialField(`${field.name}:${field.type}`); + if (fieldNames.has(validated.name)) { + throw new Error(`Duplicate credential field name: ${validated.name}`); + } + fieldNames.add(validated.name); + return validated; + }), + ); + const commandArgv = Object.freeze([...options.commandArgv]); + if ( + commandArgv.length === 0 || + !commandArgv[0] || + commandArgv.some((arg) => arg.includes("\0")) + ) { + throw new Error("Local credential helper requires a NUL-free executable and argv"); + } + const timeoutMs = options.timeoutMs ?? SESSION_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error("Local credential helper timeout must be a positive number"); + } + const formBytes = Buffer.from(options.formBytes); + const trustedEnv = Object.freeze({ ...process.env }); + const capabilityBytes = randomBytes(32); + const capability = capabilityBytes.toString("base64url"); + const formRequestTarget = buildFormRequestTarget(fields); + const formCsp = buildCredentialFormCsp(formBytes); + const sockets = new Set(); + let state: SessionState = "pending"; + let expectedHost = ""; + let expectedOrigin = ""; + let child: ChildProcess | null = null; + let resolveCompletion: (code: number) => void = () => undefined; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + + const server = createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (request, response) => { + void handleRequest(request, response).catch((error: unknown) => { + request.resume(); + sendRequestError(response, error); + }); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.requestTimeout = 15_000; + server.headersTimeout = 10_000; + server.keepAliveTimeout = 1_000; + server.maxHeadersCount = 32; + + const finishWithoutChild = (nextState: "expired" | "closed", message: string): void => { + if (state !== "pending") return; + state = nextState; + capabilityBytes.fill(0); + stopAcceptingConnections(server); + destroySockets(sockets); + console.error(message); + resolveCompletion(1); + }; + + const timeout = setTimeout( + () => finishWithoutChild("expired", "Local credential helper expired before confirmation."), + timeoutMs, + ); + + const launchApprovedCommand = (values: Record): void => { + const childEnv: NodeJS.ProcessEnv = { ...trustedEnv, ...values }; + try { + child = spawn(commandArgv[0], commandArgv.slice(1), { + env: childEnv, + shell: false, + stdio: "inherit", + }); + } catch (error) { + scrubValues(childEnv as Record, fields); + scrubValues(values, fields); + console.error( + `Local credential helper could not start the approved command: ${error instanceof Error ? error.message : String(error)}`, + ); + resolveCompletion(1); + return; + } + scrubValues(childEnv as Record, fields); + scrubValues(values, fields); + console.error("Approved command started."); + child.once("error", (error) => { + console.error(`Approved command failed to start: ${error.message}`); + resolveCompletion(1); + }); + child.once("exit", (code, signal) => { + if (signal) { + console.error(`Approved command exited after signal ${signal}.`); + resolveCompletion(1); + return; + } + resolveCompletion(code ?? 1); + }); + }; + + async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + const host = requireSingleHeader(request, "host"); + if (host !== expectedHost) throw new RequestError(421, "Request Host is not the local helper"); + + if (request.method === "GET" && request.url === formRequestTarget) { + if (state !== "pending") + throw new RequestError(410, "Credential session is no longer active"); + addCommonResponseHeaders(response); + response.writeHead(200, { + "Content-Length": formBytes.length, + "Content-Security-Policy": formCsp, + "Content-Type": "text/html; charset=utf-8", + }); + response.end(formBytes); + return; + } + + if (request.method !== "POST" || request.url !== LOCAL_CREDENTIAL_SUBMIT_PATH) { + throw new RequestError(404, "Not found"); + } + if (state !== "pending") throw new RequestError(409, "Credential session was already claimed"); + if (requireSingleHeader(request, "origin") !== expectedOrigin) { + throw new RequestError(403, "Request Origin is not the local helper"); + } + if (requireSingleHeader(request, "content-type").trim().toLowerCase() !== "application/json") { + throw new RequestError(415, "Content-Type must be application/json"); + } + if (rawHeaderValues(request, "content-encoding").length !== 0) { + throw new RequestError(415, "Content-Encoding is not supported"); + } + if (rawHeaderValues(request, "transfer-encoding").length !== 0) { + throw new RequestError(400, "Transfer-Encoding is not supported"); + } + if (!capabilityMatches(request, capabilityBytes)) { + throw new RequestError(403, "Credential capability is invalid"); + } + + const contentLengthValues = rawHeaderValues(request, "content-length"); + if (contentLengthValues.length > 1) { + throw new RequestError(400, "Content-Length must not be repeated"); + } + if (contentLengthValues.length === 1) { + const contentLength = Number(contentLengthValues[0]); + if (!Number.isInteger(contentLength) || contentLength < 0) { + throw new RequestError(400, "Content-Length is invalid"); + } + if (contentLength > MAX_BODY_BYTES) throw new RequestError(413, "Request body is too large"); + } + + const body = await readBoundedBody(request); + let values: Record; + try { + values = parseSubmittedValues(body, fields); + } finally { + body.fill(0); + } + + // Recheck after the asynchronous body read. JavaScript executes this claim + // synchronously, so exactly one concurrent valid request can transition the + // session and launch the approved command. + if (state !== "pending") { + scrubValues(values, fields); + throw new RequestError(409, "Credential session was already claimed"); + } + state = "claimed"; + clearTimeout(timeout); + capabilityBytes.fill(0); + stopAcceptingConnections(server); + destroySockets(sockets, request.socket); + response.once("finish", () => request.socket.destroy()); + sendJson(response, 202, { accepted: true }); + launchApprovedCommand(values); + } + + server.once("error", (error) => { + if (state === "pending") { + state = "closed"; + clearTimeout(timeout); + capabilityBytes.fill(0); + stopAcceptingConnections(server); + destroySockets(sockets); + console.error(`Local credential helper server failed: ${error.message}`); + resolveCompletion(1); + } + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, LOCAL_CREDENTIAL_HELPER_HOST, () => { + server.off("error", onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + finishWithoutChild("closed", "Local credential helper could not determine its local port."); + throw new Error("Local credential helper did not acquire a TCP address"); + } + expectedHost = `${LOCAL_CREDENTIAL_HELPER_HOST}:${address.port}`; + expectedOrigin = `http://${expectedHost}`; + const url = `${expectedOrigin}${formRequestTarget}#cap=${capability}`; + + const forwardSignal = (signal: NodeJS.Signals): void => { + if (child) { + child.kill(signal); + return; + } + clearTimeout(timeout); + finishWithoutChild("closed", `Local credential helper stopped by ${signal}.`); + }; + const onSigint = (): void => forwardSignal("SIGINT"); + const onSigterm = (): void => forwardSignal("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + + completion.finally(() => { + clearTimeout(timeout); + capabilityBytes.fill(0); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + stopAcceptingConnections(server); + if (state !== "claimed") destroySockets(sockets); + }); + + return Object.freeze({ completion, origin: expectedOrigin, server, url }); +} + +export async function main(argv: readonly string[] = process.argv.slice(2)): Promise { + const options = parseCliArguments(argv); + const formBytes = loadVerifiedCredentialForm(options.formPath); + const session = await startLocalCredentialHelper({ + commandArgv: options.commandArgv, + fields: options.fields, + formBytes, + }); + console.error("Open this one-time local URL in the coding-agent browser:"); + console.log(session.url); + return session.completion; +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) { + void main() + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts new file mode 100644 index 0000000000..c78a3c47cd --- /dev/null +++ b/test/local-credential-helper.test.ts @@ -0,0 +1,561 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type ChildProcess, 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"; + +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"; + +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; +} + +const activeChildren = new Set(); +const tempDirs = new Set(); + +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[]): CapturedChild { + const child = spawn(process.execPath, args, { + cwd: REPO_ROOT, + env: { ...process.env, 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; +} + +async function terminate(captured: CapturedChild): Promise { + if (captured.child.exitCode !== null || captured.child.signalCode !== null) { + await captured.closed.catch(() => undefined); + return; + } + captured.child.kill("SIGTERM"); + try { + await withTimeout(captured.closed, 1_000, "credential helper SIGTERM"); + } catch { + captured.child.kill("SIGKILL"); + await captured.closed.catch(() => undefined); + } +} + +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 { + if (timer !== undefined) clearTimeout(timer); + } +} + +function helperArgs(fields: string[], command: string[]): string[] { + return [ + "--experimental-strip-types", + HELPER_PATH, + "--form", + FORM_PATH, + ...fields.flatMap((field) => ["--field", field]), + "--", + ...command, + ]; +} + +async function waitForReadiness(captured: CapturedChild): Promise { + const deadline = Date.now() + PROCESS_TIMEOUT_MS; + while (Date.now() < deadline) { + const match = captured.output().match(READINESS_URL_PATTERN); + if (match) return new URL(match[0]); + if (captured.child.exitCode !== null || captured.child.signalCode !== null) { + const result = await captured.closed; + throw new Error( + `credential helper exited before readiness (${result.code ?? result.signal}):\n${captured.output()}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`credential helper did not report readiness:\n${captured.output()}`); +} + +async function startHelper(command: string[]): Promise { + const captured = captureChild(helperArgs(["OPENAI_API_KEY:secret", "PUBLIC_ID:text"], command)); + 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 [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);', + '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 headers = { ...(options.headers ?? {}) }; + const hasContentLength = Object.keys(headers).some( + (name) => name.toLowerCase() === "content-length", + ); + if (body !== undefined && !hasContentLength && !options.omitContentLength) { + headers["content-length"] = String(body.length); + } + + 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); + if (body !== undefined) clientRequest.write(body); + clientRequest.end(); + }); +} + +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 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); +} + +describe("local credential helper", () => { + 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: ["LD_PRELOAD:secret"], label: "dynamic-loader 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", 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("serves only the exact form bytes with hardened non-CORS headers", 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("rejects Host, Origin, capability, and CORS probes without consuming the session", 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", 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", 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", 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..a903e35191 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -28,12 +28,12 @@ const localCredentialFormSource = path.join( const localCredentialFormUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/c9aac7dc12bacdaa4d38af552b893021049ee836/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = - "cc746703ab514cf33d7131915f16e8dc19346b26a4d953c5125be81449d6e6f6"; // gitleaks:allow -- checked-in SHA-256 fixture + "b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ - "'sha256-7knX1kPQ", - "ir4x3z0uoR2GmEi9", - "hb0+82UEW2o9BzJD", - "520='", + "'sha256-9SWtYAX3", + "k4sfukZTBTjWRvoQ", + "kNyDDtLbUDlV2rK6", + "PyQ='", ].join(""); const localCredentialFormStyleCspHash = [ "'sha256-W4wSJyrm", @@ -41,6 +41,7 @@ const localCredentialFormStyleCspHash = [ "msaHh6dbUj9ZlKh", "xipME='", ].join(""); +const localCredentialCapability = "A".repeat(43); const starterPromptPages = [ "docs/index.mdx", "docs/get-started/quickstart.mdx", @@ -57,6 +58,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 +111,10 @@ class FakeElement { autocomplete = ""; className = ""; disabled = false; + hidden = false; id = ""; name = ""; + readOnly = false; required = false; spellcheck = true; textContent = ""; @@ -137,11 +146,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 +174,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 +185,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 +219,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 +266,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"); @@ -317,25 +367,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 +405,73 @@ 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=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=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 +483,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 +495,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("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 submit outside loopback and shows helper-friendly failures (#5048)", async () => { + 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)", () => { From e4b81c0b5ea03eda4e629efbe406137e16180dcc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 20:15:22 -0700 Subject: [PATCH 02/20] docs(security): pin reviewed credential workflow Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 39 ++-- docs/get-started/quickstart-hermes.mdx | 2 +- .../quickstart-langchain-deepagents-code.mdx | 2 +- docs/get-started/quickstart.mdx | 2 +- docs/index.mdx | 2 +- docs/resources/agent-skills.mdx | 2 +- scripts/checks/local-credential-helper-pin.ts | 177 ++++++++++++++++++ scripts/checks/run.ts | 5 + test/starter-prompt-docs.test.ts | 26 ++- 9 files changed, 230 insertions(+), 27 deletions(-) create mode 100644 scripts/checks/local-credential-helper-pin.ts diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index b625069ae8..c8c8603f96 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -89,20 +89,29 @@ 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, prefer the checked-in NemoClaw local credential helper and form instead of chat. + +- Before starting credential collection, determine 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: + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/scripts/local-credential-helper.mts (SHA-256 \`fa93b22b0c14350b4ed768550124c5f015ed6d902a264266d38eacf95e6d203f\`) + - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/docs/resources/local-credential-form.html (SHA-256 \`b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68\`) +- Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. +- 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. +- 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 helper or form code. +- Use the helper's repeated \`--field NAME:type\` arguments, followed by a literal \`--\` and the exact approved argv. For example: + +\`\`\`shell +node --experimental-strip-types /local-credential-helper.mts --form /local-credential-form.html --field NVIDIA_INFERENCE_API_KEY:secret -- +\`\`\` + +- Use \`:secret\` for every secret. Use \`:text\` only for non-secret IDs, allowlists, endpoint URLs, model names, and sandbox names. The helper must reject credential-looking \`:text\` fields and process-control environment names. +- Keep real credentials out of argv. The helper supplies accepted values only through the child process environment and invokes the frozen argv without adding a shell. +- Open only the one-time \`http://127.0.0.1\` URL printed by the helper in the coding-agent UI's browser. Do not paste that URL or its fragment capability into chat. +- In the form, enter the values and choose **Preview Credentials**. Preview is local-only: it clears the inputs and shows a redacted summary without sending a request. Choose **Edit** to discard that snapshot and re-enter every value. +- Choose **Confirm and Run Approved Command** only after the redacted summary matches the command I already approved. That click makes the form's sole credential-bearing request; the helper accepts at most one valid submission, closes its listener, and starts the exact approved argv. +- If the form says the outcome is unknown, do not retry or resubmit. Inspect the coding-agent terminal to determine whether the command ran, then start a fresh helper session only if needed. +- Keep submitted secrets only in memory long enough to start the approved command. Do not print them, write them to logs, commit them, or paste them into chat. +- After the command finishes, delete any fetched helper and form copies and their private temporary directory. Use this provider mapping for non-interactive setup: @@ -118,7 +127,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\`. +For a credentialed \`curl | bash\` install, make the exact approved argv invoke \`bash -c\` with a script that copies each credential into a non-exported shell variable, unsets the exported credential before starting \`curl\`, and supplies it only in the environment assignments on the \`bash\` side of the pipe. Use variable references in that script, never real credential values in argv. 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 68dbe6b75b..48d04e7849 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/scripts/checks/local-credential-helper-pin.ts b/scripts/checks/local-credential-helper-pin.ts new file mode 100644 index 0000000000..1ae334d4e8 --- /dev/null +++ b/scripts/checks/local-credential-helper-pin.ts @@ -0,0 +1,177 @@ +// 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"; + +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"; + +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 = helperSource.match( + /EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256\s*=\s*\n?\s*"([a-f0-9]{64})"/, + )?.[1]; + 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 extractCredentialPattern(source: string, relativePath: string): string { + const pattern = source.match( + /CREDENTIAL_SHAPED_NAME_PATTERN\s*=\s*\n?\s*(\/[^\n]+\/[a-z]*);/, + )?.[1]; + if (!pattern) throw new Error(`${relativePath}: credential-shaped name pattern is missing`); + return pattern; +} + +function extractStringSet(source: string, setName: string, relativePath: string): string[] { + const body = source.match( + new RegExp(`(?:const\\s+)?${setName}\\s*=\\s*new Set\\(\\[([\\s\\S]*?)\\]\\);`), + )?.[1]; + if (!body) throw new Error(`${relativePath}: ${setName} is missing`); + return [...body.matchAll(/"([A-Z0-9_]+)"/g)].map((match) => match[1]).sort(); +} + +function verifyFieldSafetyRules(): string[] { + const helperSource = fs.readFileSync(path.join(REPO_ROOT, HELPER_PATH), "utf8"); + const formSource = fs.readFileSync(path.join(REPO_ROOT, FORM_PATH), "utf8"); + const failures: string[] = []; + if ( + extractCredentialPattern(helperSource, HELPER_PATH) !== + extractCredentialPattern(formSource, FORM_PATH) + ) { + failures.push("helper and form credential-shaped name patterns must match exactly"); + } + const helperControlNames = extractStringSet( + helperSource, + "FORBIDDEN_CHILD_ENV_NAMES", + HELPER_PATH, + ); + const formControlNames = extractStringSet(formSource, "PROCESS_CONTROL_FIELD_NAMES", FORM_PATH); + if (helperControlNames.join("\n") !== formControlNames.join("\n")) { + failures.push("helper and form process-control environment name sets must match exactly"); + } + return failures; +} + +function main(): void { + const promptSource = fs.readFileSync(path.join(REPO_ROOT, STARTER_PROMPT_PATH), "utf8"); + const section = findCredentialSection(promptSource); + const sectionDigests = [...section.matchAll(/\b[a-f0-9]{64}\b/g)].map(([digest]) => digest); + const expectedDigests = REVIEWED_ARTIFACTS.map(({ relativePath }) => + sha256(fs.readFileSync(path.join(REPO_ROOT, relativePath))), + ); + const pinnedCommits = REVIEWED_ARTIFACTS.flatMap(({ relativePath }) => { + const pattern = new RegExp( + `https://raw\\.githubusercontent\\.com/NVIDIA/NemoClaw/([0-9a-f]{40})/${escapeRegExp(relativePath)}`, + ); + const commit = section.match(pattern)?.[1]; + return commit ? [commit] : []; + }); + const failures = [ + ...REVIEWED_ARTIFACTS.flatMap((artifact) => verifyArtifact(section, artifact)), + ...verifyEmbeddedFormDigest(), + ...verifyFieldSafetyRules(), + ...verifyPackageFiles(), + ]; + if ( + sectionDigests.length !== expectedDigests.length || + [...sectionDigests].sort().join("\n") !== [...expectedDigests].sort().join("\n") + ) { + failures.push("starter prompt credential section must contain only the two current digests"); + } + if (pinnedCommits.length !== REVIEWED_ARTIFACTS.length || new Set(pinnedCommits).size !== 1) { + failures.push("starter prompt helper and form URLs must pin the same commit"); + } + + if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); + } + console.log("Local credential helper and form pins are immutable and current."); +} + +main(); diff --git a/scripts/checks/run.ts b/scripts/checks/run.ts index 9248bb9041..8e372c193c 100644 --- a/scripts/checks/run.ts +++ b/scripts/checks/run.ts @@ -26,6 +26,11 @@ const CHECKS: readonly CheckCommand[] = [ "src/lib/onboard/providers.ts", ], }, + { + name: "local-credential-helper-pin", + command: TSX, + args: ["scripts/checks/local-credential-helper-pin.ts"], + }, { name: "no-coverage-ignore", command: TSX, diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index a903e35191..660e1032fb 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -25,8 +25,12 @@ const localCredentialFormSource = path.join( "resources", "local-credential-form.html", ); +const localCredentialHelperUrl = + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/scripts/local-credential-helper.mts"; +const localCredentialHelperSha256 = + "fa93b22b0c14350b4ed768550124c5f015ed6d902a264266d38eacf95e6d203f"; // 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/6546c085a76f1001ac89a5e47b8179067a3f617a/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = "b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ @@ -330,19 +334,28 @@ 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("Confirm and Run Approved Command"); + expect(promptSource).toContain("do not retry or resubmit"); + expect(promptSource).toContain("unsets the exported credential before starting \\`curl\\`"); expect(formSource).toContain("NemoClaw Local Credential Form"); expect(formSource).toContain("Content-Security-Policy"); expect(formSource).toContain("connect-src 'self';"); @@ -356,7 +369,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")'); From f6d20f12e0388df411453bf654c00081f512a915 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 20:23:17 -0700 Subject: [PATCH 03/20] test(security): linearize credential helper harness Signed-off-by: Carlos Villela --- test/local-credential-helper.test.ts | 68 +++++++++++++++++++--------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts index c78a3c47cd..8c66473ce9 100644 --- a/test/local-credential-helper.test.ts +++ b/test/local-credential-helper.test.ts @@ -49,6 +49,8 @@ interface RequestOptions { path?: string; } +type ReadinessState = { kind: "exited" } | { kind: "ready"; url: string } | { kind: "waiting" }; + const activeChildren = new Set(); const tempDirs = new Set(); @@ -86,20 +88,28 @@ function captureChild(args: string[]): CapturedChild { return captured; } -async function terminate(captured: CapturedChild): Promise { - if (captured.child.exitCode !== null || captured.child.signalCode !== null) { - await captured.closed.catch(() => undefined); - return; - } +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 captured.closed.catch(() => undefined); + 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 { @@ -110,7 +120,7 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str }), ]); } finally { - if (timer !== undefined) clearTimeout(timer); + clearTimeout(timer); } } @@ -126,18 +136,31 @@ function helperArgs(fields: string[], command: string[]): string[] { ]; } +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 match = captured.output().match(READINESS_URL_PATTERN); - if (match) return new URL(match[0]); - if (captured.child.exitCode !== null || captured.child.signalCode !== null) { - const result = await captured.closed; - throw new Error( - `credential helper exited before readiness (${result.code ?? result.signal}):\n${captured.output()}`, - ); + 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)); } - await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error(`credential helper did not report readiness:\n${captured.output()}`); } @@ -192,13 +215,15 @@ function createCommandFixture(): { function request(url: URL, options: RequestOptions = {}): Promise { const body = typeof options.body === "string" ? Buffer.from(options.body, "utf8") : options.body; - const headers = { ...(options.headers ?? {}) }; - const hasContentLength = Object.keys(headers).some( + const suppliedHeaders = options.headers ?? {}; + const hasContentLength = Object.keys(suppliedHeaders).some( (name) => name.toLowerCase() === "content-length", ); - if (body !== undefined && !hasContentLength && !options.omitContentLength) { - headers["content-length"] = String(body.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( @@ -226,8 +251,7 @@ function request(url: URL, options: RequestOptions = {}): Promise { clientRequest.destroy(new Error("credential helper request timed out")); }); clientRequest.on("error", reject); - if (body !== undefined) clientRequest.write(body); - clientRequest.end(); + clientRequest.end(body); }); } From a5d8d8beb09150e67cc481940f3d461b6a2d092f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 20:32:46 -0700 Subject: [PATCH 04/20] fix(security): pin credential form descriptor Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 2 +- scripts/local-credential-helper.mts | 42 ++++++++++++++++++++++------ test/local-credential-helper.test.ts | 25 +++++++++++++++-- test/starter-prompt-docs.test.ts | 2 +- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index c8c8603f96..7d04e7f15d 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,7 +93,7 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Before starting credential collection, determine 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: - - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/scripts/local-credential-helper.mts (SHA-256 \`fa93b22b0c14350b4ed768550124c5f015ed6d902a264266d38eacf95e6d203f\`) + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/scripts/local-credential-helper.mts (SHA-256 \`827d47d99bc3b28864f2d29ca2b8480899f9fee21f536e94c0bc1f50e482237d\`) - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/docs/resources/local-credential-form.html (SHA-256 \`b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68\`) - Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. - 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. diff --git a/scripts/local-credential-helper.mts b/scripts/local-credential-helper.mts index e901f9acbd..bc2644e83a 100755 --- a/scripts/local-credential-helper.mts +++ b/scripts/local-credential-helper.mts @@ -2,9 +2,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; -import { readFileSync, statSync } from "node:fs"; +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; import path from "node:path"; @@ -226,12 +226,7 @@ export function loadVerifiedCredentialForm( "Local credential form SHA-256 is not finalized in scripts/local-credential-helper.mts", ); } - const stat = statSync(formPath); - if (!stat.isFile()) throw new Error(`Local credential form is not a regular file: ${formPath}`); - if (stat.size <= 0 || stat.size > MAX_FORM_BYTES) { - throw new Error(`Local credential form must be between 1 and ${MAX_FORM_BYTES} bytes`); - } - const bytes = readFileSync(formPath); + const bytes = readBoundedCredentialForm(formPath); const actualSha256 = createHash("sha256").update(bytes).digest("hex"); if (!timingSafeStringEqual(actualSha256, expectedSha256)) { throw new Error(`Local credential form SHA-256 mismatch: ${formPath}`); @@ -239,6 +234,37 @@ export function loadVerifiedCredentialForm( return bytes; } +function readBoundedCredentialForm(formPath: string): Buffer { + const fileDescriptor = openSync(formPath, constants.O_RDONLY); + try { + const stat = fstatSync(fileDescriptor); + if (!stat.isFile()) { + throw new Error(`Local credential form is not a regular file: ${formPath}`); + } + if (stat.size <= 0 || stat.size > MAX_FORM_BYTES) { + throw new Error(`Local credential form must be between 1 and ${MAX_FORM_BYTES} bytes`); + } + + const bytes = Buffer.alloc(stat.size); + let offset = 0; + while (offset < bytes.length) { + const count = readSync(fileDescriptor, bytes, offset, bytes.length - offset, null); + if (count === 0) { + throw new Error(`Local credential form changed while being read: ${formPath}`); + } + offset += count; + } + + const extraByte = Buffer.alloc(1); + if (readSync(fileDescriptor, extraByte, 0, 1, null) !== 0) { + throw new Error(`Local credential form changed while being read: ${formPath}`); + } + return bytes; + } finally { + closeSync(fileDescriptor); + } +} + function timingSafeStringEqual(actual: string, expected: string): boolean { const actualBytes = Buffer.from(actual); const expectedBytes = Buffer.from(expected); diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts index 8c66473ce9..0ae8c594b6 100644 --- a/test/local-credential-helper.test.ts +++ b/test/local-credential-helper.test.ts @@ -124,12 +124,12 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str } } -function helperArgs(fields: string[], command: string[]): string[] { +function helperArgs(fields: string[], command: string[], formPath = FORM_PATH): string[] { return [ "--experimental-strip-types", HELPER_PATH, "--form", - FORM_PATH, + formPath, ...fields.flatMap((field) => ["--field", field]), "--", ...command, @@ -330,6 +330,27 @@ describe("local credential helper", () => { expect(captured.output()).not.toMatch(READINESS_URL_PATTERN); }); + it("rejects a modified credential form before listening", 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", async () => { const fixture = createCommandFixture(); const helper = await startHelper(fixture.command); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 660e1032fb..3fe306352b 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -28,7 +28,7 @@ const localCredentialFormSource = path.join( const localCredentialHelperUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/scripts/local-credential-helper.mts"; const localCredentialHelperSha256 = - "fa93b22b0c14350b4ed768550124c5f015ed6d902a264266d38eacf95e6d203f"; // gitleaks:allow -- checked-in SHA-256 fixture + "827d47d99bc3b28864f2d29ca2b8480899f9fee21f536e94c0bc1f50e482237d"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = From 53cd27fa1dcf1c9426bfde750ac594f16b61c781 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 20:33:27 -0700 Subject: [PATCH 05/20] docs(security): repin credential helper artifact Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 4 ++-- test/starter-prompt-docs.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 7d04e7f15d..40c5e63484 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,8 +93,8 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Before starting credential collection, determine 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: - - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/scripts/local-credential-helper.mts (SHA-256 \`827d47d99bc3b28864f2d29ca2b8480899f9fee21f536e94c0bc1f50e482237d\`) - - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/docs/resources/local-credential-form.html (SHA-256 \`b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68\`) + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/a5d8d8beb09150e67cc481940f3d461b6a2d092f/scripts/local-credential-helper.mts (SHA-256 \`827d47d99bc3b28864f2d29ca2b8480899f9fee21f536e94c0bc1f50e482237d\`) + - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/a5d8d8beb09150e67cc481940f3d461b6a2d092f/docs/resources/local-credential-form.html (SHA-256 \`b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68\`) - Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. - 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. - 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 helper or form code. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 3fe306352b..99feea5800 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,11 +26,11 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialHelperUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/scripts/local-credential-helper.mts"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/a5d8d8beb09150e67cc481940f3d461b6a2d092f/scripts/local-credential-helper.mts"; const localCredentialHelperSha256 = "827d47d99bc3b28864f2d29ca2b8480899f9fee21f536e94c0bc1f50e482237d"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/6546c085a76f1001ac89a5e47b8179067a3f617a/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/a5d8d8beb09150e67cc481940f3d461b6a2d092f/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = "b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ From 854a13265e5821cdd245c72fca8ebc14a2589993 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 20:52:45 -0700 Subject: [PATCH 06/20] fix(security): address credential review findings Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 3 +- scripts/checks/local-credential-helper-pin.ts | 241 ++++++++++++++++-- scripts/local-credential-helper.mts | 27 +- test/local-credential-helper-pin.test.ts | 183 +++++++++++++ test/local-credential-helper.test.ts | 17 +- test/starter-prompt-docs.test.ts | 8 +- 6 files changed, 440 insertions(+), 39 deletions(-) create mode 100644 test/local-credential-helper-pin.test.ts diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 40c5e63484..16c89be408 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,7 +93,7 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Before starting credential collection, determine 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: - - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/a5d8d8beb09150e67cc481940f3d461b6a2d092f/scripts/local-credential-helper.mts (SHA-256 \`827d47d99bc3b28864f2d29ca2b8480899f9fee21f536e94c0bc1f50e482237d\`) + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/a5d8d8beb09150e67cc481940f3d461b6a2d092f/scripts/local-credential-helper.mts (SHA-256 \`54335bf6e1ace853402f74f0e656999ff6fb65cc23fc81c685bc13983b7c40f1\`) - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/a5d8d8beb09150e67cc481940f3d461b6a2d092f/docs/resources/local-credential-form.html (SHA-256 \`b604a8c355ca9ec67ae1ad368537861e78cadfa1441a55da02c43df3313aee68\`) - Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. - 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. @@ -111,6 +111,7 @@ node --experimental-strip-types /local-credential-helper.mts --form - Choose **Confirm and Run Approved Command** only after the redacted summary matches the command I already approved. That click makes the form's sole credential-bearing request; the helper accepts at most one valid submission, closes its listener, and starts the exact approved argv. - If the form says the outcome is unknown, do not retry or resubmit. Inspect the coding-agent terminal to determine whether the command ran, then start a fresh helper session only if needed. - Keep submitted secrets only in memory long enough to start the approved command. Do not print them, write them to logs, commit them, or paste them into chat. +- Treat in-memory handling as exposure minimization, not guaranteed erasure. The helper wipes mutable request buffers and promptly drops its JavaScript references, but Node.js strings and the approved child process environment cannot be reliably zeroed. - After the command finishes, delete any fetched helper and form copies and their private temporary directory. Use this provider mapping for non-interactive setup: diff --git a/scripts/checks/local-credential-helper-pin.ts b/scripts/checks/local-credential-helper-pin.ts index 1ae334d4e8..b1ecb4812f 100644 --- a/scripts/checks/local-credential-helper-pin.ts +++ b/scripts/checks/local-credential-helper-pin.ts @@ -16,6 +16,8 @@ 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"; @@ -90,29 +92,216 @@ function verifyPackageFiles(): string[] { function verifyEmbeddedFormDigest(): string[] { const helperSource = fs.readFileSync(path.join(REPO_ROOT, HELPER_PATH), "utf8"); - const embeddedDigest = helperSource.match( - /EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256\s*=\s*\n?\s*"([a-f0-9]{64})"/, - )?.[1]; + 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 extractCredentialPattern(source: string, relativePath: string): string { - const pattern = source.match( - /CREDENTIAL_SHAPED_NAME_PATTERN\s*=\s*\n?\s*(\/[^\n]+\/[a-z]*);/, - )?.[1]; - if (!pattern) throw new Error(`${relativePath}: credential-shaped name pattern is missing`); - return pattern; +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(/"; + + expect(extractCredentialPattern(source, "fixture.html")).toBe("/current_[A-Z]+/i"); + }); + it.each([ { decoy: '/* const BLOCKED_NAMES = new Set(["OLD"]); */', From f393bba599444c5b031b1ba372228bd007176055 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 21:57:31 -0700 Subject: [PATCH 11/20] fix(security): canonicalize credential child policy Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 6 +- docs/resources/local-credential-form.html | 30 ++++- scripts/checks/local-credential-helper-pin.ts | 77 +++++++++-- scripts/local-credential-helper.mts | 37 ++++-- src/lib/adapters/http/curl-args.test.ts | 8 +- src/lib/adapters/http/probe.test.ts | 9 +- src/lib/security/credential-env.test.ts | 26 ++++ src/lib/security/credential-env.ts | 14 +- src/lib/security/process-control-env.test.ts | 36 ++++++ src/lib/security/process-control-env.ts | 90 +++++++++++++ test/local-credential-helper-pin.test.ts | 120 ++++++++++++++++++ test/local-credential-helper.test.ts | 102 ++++++++++++++- test/starter-prompt-docs.test.ts | 50 ++++++-- 13 files changed, 549 insertions(+), 56 deletions(-) create mode 100644 src/lib/security/process-control-env.test.ts create mode 100644 src/lib/security/process-control-env.ts diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 150f012939..157a85b495 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,13 +93,13 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Before starting credential collection, determine 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: - - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/scripts/local-credential-helper.mts (SHA-256 \`b2fe93eaa4f4a74845570eb4f14319d7dcce0180a762315e02cf4f2076531a10\`) - - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/docs/resources/local-credential-form.html (SHA-256 \`8e135da4fae0bc75d4437a06d4a01dd486cfa03205b272207be28da3e9efc005\`) + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/scripts/local-credential-helper.mts (SHA-256 \`eb8f14939fbea3feb56bb6b90181ce7e59549c434d528bf7d371c8432597bbcc\`) + - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/docs/resources/local-credential-form.html (SHA-256 \`47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea\`) - Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. - 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. - 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 helper or form code. - Use the helper's repeated \`--field NAME:type\` arguments, followed by a literal \`--\` and the exact approved argv. For example: -- Resolve the approved executable to an absolute native executable or interpreter path before asking permission. The helper rejects PATH-based executable lookup and removes recognized ambient credential-shaped and process-control variables before overlaying only the fields collected for the approved command. Approve explicit paths for nested tools when the command needs a non-default PATH. On native Windows, do not target a \`.cmd\` or \`.bat\` file directly; use the absolute \`cmd.exe\` path and make its arguments part of the approved argv. +- Resolve the approved executable to an absolute native executable or interpreter path before asking permission. The helper rejects PATH-based executable lookup and removes recognized ambient credential-shaped and process-control variables, including proxy-routing and TLS-trust overrides, before overlaying only the fields collected for the approved command. This includes ambient \`NPM_CONFIG_*\` and \`PIP_*\` package-source settings. Approve explicit paths for nested tools when the command needs a non-default PATH. If a trusted proxy, custom CA, package registry, or Python package index is required, put non-secret routing, trust, and package-source settings in the exact approved argv using the tool's explicit flags; never put proxy credentials in argv, and use a pre-existing trusted absolute wrapper when authenticated proxy or package-source configuration is required. On native Windows, do not target a \`.cmd\` or \`.bat\` file directly; use the absolute \`cmd.exe\` path and make its arguments part of the approved argv. \`\`\`shell node --experimental-strip-types /local-credential-helper.mts --form /local-credential-form.html --field NVIDIA_INFERENCE_API_KEY:secret -- diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index 7b3fab28c9..11552596f2 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -9,7 +9,7 @@ NemoClaw Local Credential Form @@ -155,26 +155,40 @@

NemoClaw Local Credential Form

const MAX_QUERY_BYTES = 4 * 1024; const MAX_VALUE_BYTES = 16 * 1024; const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; + // The repository pin check enforces parity with the canonical security policies. const CREDENTIAL_SHAPED_NAME_PATTERN = /(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/i; const PROCESS_CONTROL_FIELD_NAMES = new Set([ + "ALL_PROXY", + "AWS_CA_BUNDLE", "BASHOPTS", "BASH_ENV", "CDPATH", "CLASSPATH", "COMSPEC", + "CURL_CA_BUNDLE", + "DENO_CERT", "DOTNET_STARTUP_HOOKS", "ENV", + "FTP_PROXY", "GIT_ASKPASS", "GIT_EDITOR", "GIT_EXEC_PATH", "GIT_EXTERNAL_DIFF", "GIT_PAGER", "GIT_PROXY_COMMAND", + "GIT_PROXY_SSL_CAINFO", "GIT_SEQUENCE_EDITOR", "GIT_SSH", "GIT_SSH_COMMAND", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", "GLOBIGNORE", + "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", + "GRPC_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", "IFS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", @@ -184,10 +198,10 @@

NemoClaw Local Credential Form

"NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", "NODE_PATH", - "NPM_CONFIG_GLOBALCONFIG", - "NPM_CONFIG_NODE_OPTIONS", - "NPM_CONFIG_SCRIPT_SHELL", - "NPM_CONFIG_USERCONFIG", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NO_PROXY", "PAGER", "PATH", "PATHEXT", @@ -198,12 +212,14 @@

NemoClaw Local Credential Form

"PYTHONINSPECT", "PYTHONPATH", "PYTHONSTARTUP", + "REQUESTS_CA_BUNDLE", "RUBYLIB", "RUBYOPT", "SHELL", "SHELLOPTS", "SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", + "SSLKEYLOGFILE", "SSL_CERT_DIR", "SSL_CERT_FILE", "_JAVA_OPTIONS", @@ -257,7 +273,9 @@

NemoClaw Local Credential Form

name === "GIT_CONFIG" || name.startsWith("GIT_CONFIG_") || name.startsWith("GIT_TRACE") || - name.startsWith("LD_") + name.startsWith("LD_") || + name.startsWith("NPM_CONFIG_") || + name.startsWith("PIP_") ); } diff --git a/scripts/checks/local-credential-helper-pin.ts b/scripts/checks/local-credential-helper-pin.ts index d3c119cbd7..344394dcdd 100644 --- a/scripts/checks/local-credential-helper-pin.ts +++ b/scripts/checks/local-credential-helper-pin.ts @@ -22,6 +22,8 @@ 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; @@ -304,33 +306,68 @@ export function extractProcessControlRules( return rules.sort(); } -function verifyFieldSafetyRules(): string[] { - const helperSource = fs.readFileSync(path.join(REPO_ROOT, HELPER_PATH), "utf8"); - const formSource = fs.readFileSync(path.join(REPO_ROOT, FORM_PATH), "utf8"); +type FieldSafetySources = Readonly<{ + canonicalCredential: string; + canonicalProcessControl: string; + form: string; + helper: string; +}>; + +export function verifyFieldSafetySourceParity(sources: FieldSafetySources): string[] { const failures: string[] = []; - if ( - extractCredentialPattern(helperSource, HELPER_PATH) !== - extractCredentialPattern(formSource, FORM_PATH) - ) { + const canonicalCredentialPattern = extractCredentialPattern( + sources.canonicalCredential, + CREDENTIAL_ENV_PATH, + ); + const helperCredentialPattern = extractCredentialPattern(sources.helper, HELPER_PATH); + const formCredentialPattern = extractCredentialPattern(sources.form, FORM_PATH); + if (helperCredentialPattern !== formCredentialPattern) { failures.push("helper and form credential-shaped name patterns must match exactly"); } + if (helperCredentialPattern !== canonicalCredentialPattern) { + failures.push("helper credential-shaped name pattern must match the canonical security policy"); + } + if (formCredentialPattern !== canonicalCredentialPattern) { + failures.push("form credential-shaped name pattern must match the canonical security policy"); + } + const canonicalControlNames = extractStringSet( + sources.canonicalProcessControl, + "PROCESS_CONTROL_ENV_NAMES", + PROCESS_CONTROL_ENV_PATH, + ); const helperControlNames = extractStringSet( - helperSource, + sources.helper, "FORBIDDEN_CHILD_ENV_NAMES", HELPER_PATH, ); - const formControlNames = extractStringSet(formSource, "PROCESS_CONTROL_FIELD_NAMES", FORM_PATH); + const formControlNames = extractStringSet(sources.form, "PROCESS_CONTROL_FIELD_NAMES", FORM_PATH); if (helperControlNames.join("\n") !== formControlNames.join("\n")) { failures.push("helper and form process-control environment name sets must match exactly"); } + if (helperControlNames.join("\n") !== canonicalControlNames.join("\n")) { + failures.push( + "helper process-control environment names must match the canonical security policy", + ); + } + if (formControlNames.join("\n") !== canonicalControlNames.join("\n")) { + failures.push( + "form process-control environment names must match the canonical security policy", + ); + } + const canonicalControlRules = extractProcessControlRules( + sources.canonicalProcessControl, + "isProcessControlEnvName", + "PROCESS_CONTROL_ENV_NAMES", + PROCESS_CONTROL_ENV_PATH, + ); const helperControlRules = extractProcessControlRules( - helperSource, + sources.helper, "isForbiddenChildEnvName", "FORBIDDEN_CHILD_ENV_NAMES", HELPER_PATH, ); const formControlRules = extractProcessControlRules( - formSource, + sources.form, "isProcessControlFieldName", "PROCESS_CONTROL_FIELD_NAMES", FORM_PATH, @@ -338,9 +375,27 @@ function verifyFieldSafetyRules(): string[] { if (helperControlRules.join("\n") !== formControlRules.join("\n")) { failures.push("helper and form process-control predicate rules must match exactly"); } + if (helperControlRules.join("\n") !== canonicalControlRules.join("\n")) { + failures.push("helper process-control predicate must match the canonical security policy"); + } + if (formControlRules.join("\n") !== canonicalControlRules.join("\n")) { + failures.push("form process-control predicate must match the canonical security policy"); + } return failures; } +function verifyFieldSafetyRules(): string[] { + return verifyFieldSafetySourceParity({ + canonicalCredential: fs.readFileSync(path.join(REPO_ROOT, CREDENTIAL_ENV_PATH), "utf8"), + canonicalProcessControl: fs.readFileSync( + path.join(REPO_ROOT, PROCESS_CONTROL_ENV_PATH), + "utf8", + ), + form: fs.readFileSync(path.join(REPO_ROOT, FORM_PATH), "utf8"), + helper: fs.readFileSync(path.join(REPO_ROOT, HELPER_PATH), "utf8"), + }); +} + function main(): void { const starterPromptSource = fs.readFileSync(path.join(REPO_ROOT, STARTER_PROMPT_PATH), "utf8"); const prompt = extractStarterPrompt(starterPromptSource, STARTER_PROMPT_PATH); diff --git a/scripts/local-credential-helper.mts b/scripts/local-credential-helper.mts index d4a13d6805..ffd662a8b4 100755 --- a/scripts/local-credential-helper.mts +++ b/scripts/local-credential-helper.mts @@ -11,7 +11,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; export const EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256 = - "8e135da4fae0bc75d4437a06d4a01dd486cfa03205b272207be28da3e9efc005"; // gitleaks:allow -- checked-in SHA-256 integrity pin + "47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea"; // gitleaks:allow -- checked-in SHA-256 integrity pin export const LOCAL_CREDENTIAL_HELPER_HOST = "127.0.0.1"; export const LOCAL_CREDENTIAL_FORM_PATH = "/local-credential-form.html"; @@ -28,30 +28,43 @@ const FIELD_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,80}$/; const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; const FINAL_FORM_SHA256_PATTERN = /^[a-f0-9]{64}$/; -// Keep this shape aligned with src/lib/security/credential-env.ts. This helper -// must remain standalone so a coding agent can run an integrity-checked copy -// before NemoClaw is installed. +// This helper must remain standalone so a coding agent can run an +// integrity-checked copy before NemoClaw is installed. The repository pin check +// enforces exact parity with src/lib/security/credential-env.ts. export const CREDENTIAL_SHAPED_NAME_PATTERN = /(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/i; const FORBIDDEN_CHILD_ENV_NAMES = new Set([ + "ALL_PROXY", + "AWS_CA_BUNDLE", "BASHOPTS", "BASH_ENV", "CDPATH", "CLASSPATH", "COMSPEC", + "CURL_CA_BUNDLE", + "DENO_CERT", "DOTNET_STARTUP_HOOKS", "ENV", + "FTP_PROXY", "GIT_ASKPASS", "GIT_EDITOR", "GIT_EXEC_PATH", "GIT_EXTERNAL_DIFF", "GIT_PAGER", "GIT_PROXY_COMMAND", + "GIT_PROXY_SSL_CAINFO", "GIT_SEQUENCE_EDITOR", "GIT_SSH", "GIT_SSH_COMMAND", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", "GLOBIGNORE", + "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", + "GRPC_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", "IFS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", @@ -61,10 +74,10 @@ const FORBIDDEN_CHILD_ENV_NAMES = new Set([ "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", "NODE_PATH", - "NPM_CONFIG_GLOBALCONFIG", - "NPM_CONFIG_NODE_OPTIONS", - "NPM_CONFIG_SCRIPT_SHELL", - "NPM_CONFIG_USERCONFIG", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NO_PROXY", "PAGER", "PATH", "PATHEXT", @@ -75,12 +88,14 @@ const FORBIDDEN_CHILD_ENV_NAMES = new Set([ "PYTHONINSPECT", "PYTHONPATH", "PYTHONSTARTUP", + "REQUESTS_CA_BUNDLE", "RUBYLIB", "RUBYOPT", "SHELL", "SHELLOPTS", "SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", + "SSLKEYLOGFILE", "SSL_CERT_DIR", "SSL_CERT_FILE", "_JAVA_OPTIONS", @@ -140,7 +155,9 @@ export function isForbiddenChildEnvName(name: string): boolean { name.startsWith("DYLD_") || name === "GIT_CONFIG" || name.startsWith("GIT_CONFIG_") || - name.startsWith("GIT_TRACE") + name.startsWith("GIT_TRACE") || + name.startsWith("NPM_CONFIG_") || + name.startsWith("PIP_") ); } @@ -474,7 +491,7 @@ export function buildCredentialFormCsp(formBytes: Buffer): string { } function extractSingleInlineTag(source: string, tagName: "script" | "style"): string { - const matches = [...source.matchAll(new RegExp(`<${tagName}>([\\s\\S]*?)`, "g"))]; + const matches = [...source.matchAll(new RegExp(`<${tagName}>([\\s\\S]*?)`, "gi"))]; if (matches.length !== 1 || matches[0][1] === undefined) { throw new Error(`Local credential form must contain exactly one inline <${tagName}> block`); } diff --git a/src/lib/adapters/http/curl-args.test.ts b/src/lib/adapters/http/curl-args.test.ts index 861795a488..78e5ca00f8 100644 --- a/src/lib/adapters/http/curl-args.test.ts +++ b/src/lib/adapters/http/curl-args.test.ts @@ -43,9 +43,15 @@ describe("validateCurlProbeArgs — credential-leak defence", () => { "x-api-key", "access_key", "access-key", + "authorization", + "passcode", + "cookie", + "dsn", + "connection_string", + "webhook_url", "password", "credential", - ])("rejects a credential-shaped %s query parameter so secrets cannot reach argv", (paramName) => { + ])("rejects a credential-shaped %s query parameter so secrets cannot reach argv (#5048)", (paramName) => { expect(() => validateCurlProbeArgs([ "-sS", diff --git a/src/lib/adapters/http/probe.test.ts b/src/lib/adapters/http/probe.test.ts index 5fa66f75e9..0869fb1923 100644 --- a/src/lib/adapters/http/probe.test.ts +++ b/src/lib/adapters/http/probe.test.ts @@ -405,12 +405,16 @@ describe("http-probe helpers", () => { } }); - it("strips credential-shaped opts.env entries when trustedConfigFiles is supplied", () => { + it("strips expanded credential-shaped opts.env entries with trusted config (#5048)", () => { const configPath = path.join(os.tmpdir(), "nemoclaw-trusted-curl.conf"); let spawnedEnv: NodeJS.ProcessEnv | undefined; runCurlProbe(["-sS", "--config", configPath, "https://example.test/models"], { trustedConfigFiles: [configPath], - env: { NVIDIA_API_KEY: "nvapi-should-not-leak", MY_BENIGN_VAR: "should-survive" }, + env: { + CONNECTIONSTRING: "should-not-leak", + NVIDIA_API_KEY: "nvapi-should-not-leak", + MY_BENIGN_VAR: "should-survive", + }, spawnSyncImpl: (_command, _args, options) => { spawnedEnv = options.env as NodeJS.ProcessEnv; return { @@ -424,6 +428,7 @@ describe("http-probe helpers", () => { }, }); + expect(spawnedEnv?.CONNECTIONSTRING).toBeUndefined(); expect(spawnedEnv?.NVIDIA_API_KEY).toBeUndefined(); expect(spawnedEnv?.MY_BENIGN_VAR).toBe("should-survive"); }); diff --git a/src/lib/security/credential-env.test.ts b/src/lib/security/credential-env.test.ts index a819fdec04..961acf9f44 100644 --- a/src/lib/security/credential-env.test.ts +++ b/src/lib/security/credential-env.test.ts @@ -42,6 +42,32 @@ describe("isCredentialShapedName", () => { } }); + it("matches sensitive compound names shared with local credential capture (#5048)", () => { + for (const name of [ + "PRIVATE_KEY", + "privateKey", + "passcode", + "personalAccessToken", + "connection_string", + "webhookURL", + "authorization", + "bearerToken", + "cookies", + "PAT", + "PIN", + "DSN", + "connectionstring", + ]) { + expect(isCredentialShapedName(name)).toBe(true); + } + }); + + it("does not overmatch sensitive-looking substrings inside benign names (#5048)", () => { + for (const name of ["SPIN", "DISPATCH", "COOKIEJAR", "PRIVATEERING"]) { + expect(isCredentialShapedName(name)).toBe(false); + } + }); + it("does not match benign names", () => { for (const name of ["PATH", "HOME", "NO_PROXY", "HTTP_PROXY", "LANG", "monkey", "keyboard"]) { expect(isCredentialShapedName(name)).toBe(false); diff --git a/src/lib/security/credential-env.ts b/src/lib/security/credential-env.ts index b055d228c7..7981296482 100644 --- a/src/lib/security/credential-env.ts +++ b/src/lib/security/credential-env.ts @@ -4,14 +4,14 @@ // Single source of truth for credential-shaped name detection. Both the curl // argv/URL validator (curl-args.ts) and the curl probe environment scrubber // (probe.ts) consult this so a regression in one place cannot diverge from the -// other. Covers common credential stem words (key, secret, token, password, -// auth, credential) appearing as the exact name, joined to another word with -// `_`/`-`, or in the `api`/`apikey` and other common no-separator compound -// forms (accesskey, secretkey, authtoken, refreshtoken, accesstoken, -// clientsecret) so a camelCase or run-together provider parameter cannot slip -// a secret past the validator. +// other. Covers common credential stem words and compound credential names so +// separator-free provider parameters such as `clientSecret`, browser/session +// material such as cookies, and connection strings cannot slip past the +// validator. The standalone local credential helper and browser form embed this +// literal pattern; scripts/checks/local-credential-helper-pin.ts enforces exact +// parity because those reviewed artifacts cannot import this module at runtime. export const CREDENTIAL_SHAPED_NAME_PATTERN = - /(?:^|[_-])(?:api[_-]?key|accesskey|secretkey|authtoken|refreshtoken|accesstoken|clientsecret|key|secret|token|password|passwd|auth|credential|credentials)(?:$|[_-])/i; + /(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/i; export function isCredentialShapedName(name: string): boolean { return CREDENTIAL_SHAPED_NAME_PATTERN.test(name); diff --git a/src/lib/security/process-control-env.test.ts b/src/lib/security/process-control-env.test.ts new file mode 100644 index 0000000000..3c9f6f74bd --- /dev/null +++ b/src/lib/security/process-control-env.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { isProcessControlEnvName, PROCESS_CONTROL_ENV_NAMES } from "./process-control-env"; + +describe("credential-handoff process-control environment policy", () => { + it("blocks every canonical exact environment name (#5048)", () => { + for (const name of PROCESS_CONTROL_ENV_NAMES) { + expect(isProcessControlEnvName(name)).toBe(true); + } + }); + + it.each([ + "BASH_FUNC_ECHO%%", + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_TRACE2_EVENT", + "NPM_CONFIG_REGISTRY", + "PIP_INDEX_URL", + ])("blocks the process-control rule for %s (#5048)", (name) => { + expect(isProcessControlEnvName(name)).toBe(true); + }); + + it.each([ + "HOME", + "LANG", + "PUBLIC_ID", + "SAFE_SETTING", + ])("allows unrelated environment name %s (#5048)", (name) => { + expect(isProcessControlEnvName(name)).toBe(false); + }); +}); diff --git a/src/lib/security/process-control-env.ts b/src/lib/security/process-control-env.ts new file mode 100644 index 0000000000..b6c07e0ebe --- /dev/null +++ b/src/lib/security/process-control-env.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Canonical process-control environment policy for credential-handoff children. + * + * The standalone local credential helper and browser form embed literal copies + * because they must run before NemoClaw is installed. The repository pin check + * enforces exact parity with this source. Callers pass canonical uppercase names. + */ +export const PROCESS_CONTROL_ENV_NAMES: ReadonlySet = new Set([ + "ALL_PROXY", + "AWS_CA_BUNDLE", + "BASHOPTS", + "BASH_ENV", + "CDPATH", + "CLASSPATH", + "COMSPEC", + "CURL_CA_BUNDLE", + "DENO_CERT", + "DOTNET_STARTUP_HOOKS", + "ENV", + "FTP_PROXY", + "GIT_ASKPASS", + "GIT_EDITOR", + "GIT_EXEC_PATH", + "GIT_EXTERNAL_DIFF", + "GIT_PAGER", + "GIT_PROXY_COMMAND", + "GIT_PROXY_SSL_CAINFO", + "GIT_SEQUENCE_EDITOR", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", + "GLOBIGNORE", + "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", + "GRPC_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "IFS", + "JAVA_TOOL_OPTIONS", + "JDK_JAVA_OPTIONS", + "LESSCLOSE", + "LESSOPEN", + "MANPAGER", + "NODE_EXTRA_CA_CERTS", + "NODE_OPTIONS", + "NODE_PATH", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NO_PROXY", + "PAGER", + "PATH", + "PATHEXT", + "PERL5LIB", + "PERL5OPT", + "PS4", + "PYTHONHOME", + "PYTHONINSPECT", + "PYTHONPATH", + "PYTHONSTARTUP", + "REQUESTS_CA_BUNDLE", + "RUBYLIB", + "RUBYOPT", + "SHELL", + "SHELLOPTS", + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "SSLKEYLOGFILE", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "_JAVA_OPTIONS", +]); + +export function isProcessControlEnvName(name: string): boolean { + return ( + PROCESS_CONTROL_ENV_NAMES.has(name) || + name.startsWith("BASH_FUNC_") || + name.startsWith("LD_") || + name.startsWith("DYLD_") || + name === "GIT_CONFIG" || + name.startsWith("GIT_CONFIG_") || + name.startsWith("GIT_TRACE") || + name.startsWith("NPM_CONFIG_") || + name.startsWith("PIP_") + ); +} diff --git a/test/local-credential-helper-pin.test.ts b/test/local-credential-helper-pin.test.ts index daa3bdefdc..c05e10fa8b 100644 --- a/test/local-credential-helper-pin.test.ts +++ b/test/local-credential-helper-pin.test.ts @@ -9,6 +9,7 @@ import { extractProcessControlRules, extractStarterPrompt, extractStringSet, + verifyFieldSafetySourceParity, } from "../scripts/checks/local-credential-helper-pin"; const FUNCTION_NAME = "isBlocked"; @@ -21,6 +22,8 @@ const VALID_ATOMS = [ 'name === "GIT_CONFIG"', 'name.startsWith("GIT_CONFIG_")', 'name.startsWith("GIT_TRACE")', + 'name.startsWith("NPM_CONFIG_")', + 'name.startsWith("PIP_")', ]; const EXPECTED_RULES = [ "exact:GIT_CONFIG", @@ -30,6 +33,8 @@ const EXPECTED_RULES = [ "prefix:GIT_CONFIG_", "prefix:GIT_TRACE", "prefix:LD_", + "prefix:NPM_CONFIG_", + "prefix:PIP_", ]; function predicateSource(expression: string): string { @@ -40,7 +45,120 @@ function extractRules(source: string): string[] { return extractProcessControlRules(source, FUNCTION_NAME, SET_NAME, "fixture.ts"); } +const PARITY_PATTERN = "/credential/i"; +const PARITY_NAMES = ["NODE_OPTIONS", "PATH"]; +const PARITY_PREFIXES = [ + "BASH_FUNC_", + "DYLD_", + "GIT_CONFIG_", + "GIT_TRACE", + "LD_", + "NPM_CONFIG_", + "PIP_", +]; + +function fieldSafetyScript( + pattern: string, + setName: string, + functionName: string, + names: readonly string[] = PARITY_NAMES, + prefixes: readonly string[] = PARITY_PREFIXES, +): string { + const atoms = [ + `${setName}.has(name)`, + ...prefixes.map((prefix) => `name.startsWith(${JSON.stringify(prefix)})`), + 'name === "GIT_CONFIG"', + ]; + return [ + `const CREDENTIAL_SHAPED_NAME_PATTERN = ${pattern};`, + `const ${setName} = new Set(${JSON.stringify(names)});`, + `function ${functionName}(name) { return ${atoms.join(" || ")}; }`, + ].join("\n"); +} + +function fieldSafetySources( + options: { + canonicalNames?: readonly string[]; + canonicalPattern?: string; + canonicalPrefixes?: readonly string[]; + embeddedNames?: readonly string[]; + embeddedPattern?: string; + embeddedPrefixes?: readonly string[]; + } = {}, +) { + const canonicalPattern = options.canonicalPattern ?? PARITY_PATTERN; + const embeddedPattern = options.embeddedPattern ?? PARITY_PATTERN; + const canonicalProcessControl = fieldSafetyScript( + canonicalPattern, + "PROCESS_CONTROL_ENV_NAMES", + "isProcessControlEnvName", + options.canonicalNames, + options.canonicalPrefixes, + ); + const helper = fieldSafetyScript( + embeddedPattern, + "FORBIDDEN_CHILD_ENV_NAMES", + "isForbiddenChildEnvName", + options.embeddedNames, + options.embeddedPrefixes, + ); + const formScript = fieldSafetyScript( + embeddedPattern, + "PROCESS_CONTROL_FIELD_NAMES", + "isProcessControlFieldName", + options.embeddedNames, + options.embeddedPrefixes, + ); + return { + canonicalCredential: `const CREDENTIAL_SHAPED_NAME_PATTERN = ${canonicalPattern};`, + canonicalProcessControl, + form: ``, + 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( @@ -55,6 +173,8 @@ describe("local credential helper pin predicate parity", () => { { 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: "PIP_ prefix" }, ])("detects removal of the $label (#5048)", ({ atom }) => { const mutated = VALID_ATOMS.filter((candidate) => candidate !== atom).join(" || "); diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts index 1ab52e2d33..e1a6953862 100644 --- a/test/local-credential-helper.test.ts +++ b/test/local-credential-helper.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + buildCredentialFormCsp, sanitizeInheritedChildEnvironment, startLocalCredentialHelper, } from "../scripts/local-credential-helper.mts"; @@ -23,9 +24,48 @@ const READINESS_URL_PATTERN = /http:\/\/127\.0\.0\.1:\d+\/\S*#cap=[A-Za-z0-9_-]{ const PROCESS_TIMEOUT_MS = 5_000; const TEST_SECRET = "integration-secret-value"; const TEST_PUBLIC_VALUE = "public-id"; +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_curl%%", + "BASH_FUNC_echo%%", "DOTNET_STARTUP_HOOKS", "GIT_EXEC_PATH", "GIT_CONFIG", @@ -335,10 +375,19 @@ async function expectSuccessfulCompletion( } 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("sanitizes inherited names case-insensitively without mutating the source environment (#5048)", () => { const ambient = { + ...NETWORK_TRUST_ENV_OVERRIDES, + ...PACKAGE_CONTROL_ENV_OVERRIDES, BASH_ENV: "shell-hook", - "BASH_FUNC_curl%%": '() { printf "%s" "$OPENAI_API_KEY"; }', + "BASH_FUNC_echo%%": '() { printf "%s" "$OPENAI_API_KEY"; }', DOTNET_STARTUP_HOOKS: "startup-hook", DYLD_INSERT_LIBRARIES: "loader-hook", Gh_ToKeN: "credential", @@ -372,7 +421,7 @@ describe("local credential helper", () => { }, { fields: ["PATH:text"], label: "process search path field" }, { fields: ["NODE_OPTIONS:secret"], label: "Node process-control field" }, - { fields: ["BASH_FUNC_CURL:secret"], label: "exported Bash function field prefix" }, + { 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" }, @@ -383,6 +432,12 @@ describe("local credential helper", () => { { 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" }, + ...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: ["PIP_INDEX_URL:text"], label: "pip configuration field prefix" }, { fields: ["PUBLIC_ID:text", "PUBLIC_ID:text"], label: "duplicate field", @@ -485,8 +540,10 @@ describe("local credential helper", () => { it("strips inherited credentials and process controls before launching the approved command (#5048)", async () => { const fixture = createCommandFixture(); const helper = await startHelper(fixture.command, { + ...NETWORK_TRUST_ENV_OVERRIDES, + ...PACKAGE_CONTROL_ENV_OVERRIDES, BASH_ENV: "ambient-shell-hook", - "BASH_FUNC_curl%%": '() { printf "%s" "$OPENAI_API_KEY"; }', + "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", @@ -517,6 +574,39 @@ describe("local credential helper", () => { await expectSuccessfulCompletion(helper, fixture.markerPath); }); + it("preserves explicit approved proxy and CA 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", + ]; + 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); @@ -527,13 +617,13 @@ describe("local credential helper", () => { "--noprofile", "--norc", "-c", - 'curl --version >/dev/null && test ! -e "$ATTACK_MARKER" && printf "ran\\n" > "$1"', + 'echo safe >/dev/null && test ! -e "$ATTACK_MARKER" && printf "ran\\n" > "$1"', "bash", markerPath, ]; const helper = await startHelper(command, { ATTACK_MARKER: attackMarkerPath, - "BASH_FUNC_curl%%": '() { printf "%s" "$OPENAI_API_KEY" > "$ATTACK_MARKER"; }', + "BASH_FUNC_echo%%": '() { printf "%s" "$OPENAI_API_KEY" > "$ATTACK_MARKER"; }', PATH: "/ambient/search/path", }); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 5d69bb3400..b932ffaa3c 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -28,16 +28,16 @@ const localCredentialFormSource = path.join( const localCredentialHelperUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/scripts/local-credential-helper.mts"; const localCredentialHelperSha256 = - "b2fe93eaa4f4a74845570eb4f14319d7dcce0180a762315e02cf4f2076531a10"; // gitleaks:allow -- checked-in SHA-256 fixture + "eb8f14939fbea3feb56bb6b90181ce7e59549c434d528bf7d371c8432597bbcc"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = - "8e135da4fae0bc75d4437a06d4a01dd486cfa03205b272207be28da3e9efc005"; // gitleaks:allow -- checked-in SHA-256 fixture + "47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ - "'sha256-SqI5spgA", - "FKftFmjM+eNmgnd", - "hXN96aYrBhOWbBwn", - "jFz4='", + "'sha256-+FpZq5OW", + "1xPb3W3TaGBnIC3M", + "nIL4tg1d+ZnMOwI6", + "Pko='", ].join(""); const localCredentialFormStyleCspHash = [ "'sha256-W4wSJyrm", @@ -46,6 +46,30 @@ const localCredentialFormStyleCspHash = [ "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 starterPromptPages = [ "docs/index.mdx", "docs/get-started/quickstart.mdx", @@ -354,9 +378,10 @@ describe("starter prompt docs CTA", () => { expect(promptSource).toContain("exact environment-variable names and exact command argv"); expect(promptSource).toContain("--field NAME:type"); expect(promptSource).toContain("absolute native executable or interpreter path"); - expect(promptSource).toContain( - "removes recognized ambient credential-shaped and process-control variables", - ); + expect(promptSource).toContain("including proxy-routing and TLS-trust overrides"); + expect(promptSource).toContain("ambient \\`NPM_CONFIG_*\\` and \\`PIP_*\\`"); + expect(promptSource).toContain("package registry, or Python package index"); + expect(promptSource).toContain("never put proxy credentials in argv"); expect(promptSource).toContain(" -c"); expect(promptSource).toContain("Confirm and Run Approved Command"); expect(promptSource).toContain("do not retry or resubmit"); @@ -428,7 +453,7 @@ describe("starter prompt docs CTA", () => { "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_CURL: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", @@ -440,6 +465,11 @@ describe("starter prompt docs CTA", () => { "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`, + ), + "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=PIP_INDEX_URL:text", "http://127.0.0.1:4123/local-credential-form.html?fields=PUBLIC_ID:text&submit=/capture", ]) { const malformed = runCredentialForm(withCredentialCapability(malformedUrl)); From b4e6ca6354efc97d0e9a86e34b613c853e840229 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 21:58:28 -0700 Subject: [PATCH 12/20] docs(security): repin reviewed credential artifacts Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 4 ++-- test/starter-prompt-docs.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 157a85b495..7641333056 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,8 +93,8 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Before starting credential collection, determine 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: - - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/scripts/local-credential-helper.mts (SHA-256 \`eb8f14939fbea3feb56bb6b90181ce7e59549c434d528bf7d371c8432597bbcc\`) - - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/docs/resources/local-credential-form.html (SHA-256 \`47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea\`) + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/scripts/local-credential-helper.mts (SHA-256 \`eb8f14939fbea3feb56bb6b90181ce7e59549c434d528bf7d371c8432597bbcc\`) + - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/docs/resources/local-credential-form.html (SHA-256 \`47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea\`) - Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. - 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. - 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 helper or form code. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index b932ffaa3c..e642d12651 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,11 +26,11 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialHelperUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/scripts/local-credential-helper.mts"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/scripts/local-credential-helper.mts"; const localCredentialHelperSha256 = "eb8f14939fbea3feb56bb6b90181ce7e59549c434d528bf7d371c8432597bbcc"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/24837cf4939f5cfbbdd7c7aec705be76a9e22529/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = "47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ From 786856661c24aa25c0843bfe1e5bd72f21e927a2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 22:42:02 -0700 Subject: [PATCH 13/20] fix(security): isolate credential execution profiles Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 10 +- docs/resources/local-credential-form.html | 58 ++- scripts/local-credential-helper.mts | 266 +++++++++++- src/lib/security/process-control-env.test.ts | 60 +++ src/lib/security/process-control-env.ts | 56 +++ test/local-credential-helper-pin.test.ts | 6 +- test/local-credential-helper.test.ts | 412 +++++++++++++++++-- test/starter-prompt-docs.test.ts | 84 +++- 8 files changed, 895 insertions(+), 57 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 7641333056..26d12b476b 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,16 +93,17 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Before starting credential collection, determine 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: - - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/scripts/local-credential-helper.mts (SHA-256 \`eb8f14939fbea3feb56bb6b90181ce7e59549c434d528bf7d371c8432597bbcc\`) - - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/docs/resources/local-credential-form.html (SHA-256 \`47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea\`) + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/scripts/local-credential-helper.mts (SHA-256 \`04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85\`) + - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/docs/resources/local-credential-form.html (SHA-256 \`5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8\`) - Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. - 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. - 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 helper or form code. +- Use one explicit execution profile. Choose \`--execution-profile isolated\` for stateless commands; it runs from a private temporary home and working directory that is deleted after the child exits. Choose \`--execution-profile account-home --cwd \` only when the command must persist account state, such as a NemoClaw install or onboarding run. Explain that account-home mode trusts existing configuration under the operating-system account home and the approved working directory, and ask my permission for both paths. - Use the helper's repeated \`--field NAME:type\` arguments, followed by a literal \`--\` and the exact approved argv. For example: -- Resolve the approved executable to an absolute native executable or interpreter path before asking permission. The helper rejects PATH-based executable lookup and removes recognized ambient credential-shaped and process-control variables, including proxy-routing and TLS-trust overrides, before overlaying only the fields collected for the approved command. This includes ambient \`NPM_CONFIG_*\` and \`PIP_*\` package-source settings. Approve explicit paths for nested tools when the command needs a non-default PATH. If a trusted proxy, custom CA, package registry, or Python package index is required, put non-secret routing, trust, and package-source settings in the exact approved argv using the tool's explicit flags; never put proxy credentials in argv, and use a pre-existing trusted absolute wrapper when authenticated proxy or package-source configuration is required. On native Windows, do not target a \`.cmd\` or \`.bat\` file directly; use the absolute \`cmd.exe\` path and make its arguments part of the approved argv. +- Resolve the approved executable to an absolute native executable or interpreter path before asking permission. The helper rejects PATH-based executable lookup and removes recognized ambient credential-shaped and process-control variables, including configuration roots, source/executable selectors, proxy-routing and TLS-trust overrides, and \`NPM_CONFIG_*\` and \`PIP_*\` package-source settings before applying the selected execution profile and overlaying only the fields collected for the approved command. Approve explicit paths for nested tools when the command needs a non-default PATH. If the command needs a trusted proxy, custom CA, package registry, Python package index, project directory, config file, or profile, select non-secret settings and pre-existing trusted paths in the exact approved argv using the tool's explicit flags. Never put credentials in argv. Use a pre-existing trusted absolute wrapper when the tool requires authenticated configuration or profile discovery that cannot be selected safely through argv. On native Windows, do not target a \`.cmd\` or \`.bat\` file directly; use the absolute \`cmd.exe\` path and make its arguments part of the approved argv. \`\`\`shell -node --experimental-strip-types /local-credential-helper.mts --form /local-credential-form.html --field NVIDIA_INFERENCE_API_KEY:secret -- +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. Use \`:text\` only for non-secret IDs, allowlists, endpoint URLs, model names, and sandbox names. The helper must reject credential-looking \`:text\` fields and process-control environment names. @@ -130,6 +131,7 @@ Use this provider mapping for non-interactive setup: | Model Router | \`routed\` | \`NVIDIA_INFERENCE_API_KEY\` | For a credentialed \`curl | bash\` install, make the exact approved argv invoke \` -c\` with a script that copies each credential into a non-exported shell variable, unsets the exported credential before starting \`curl\`, and supplies it only in the environment assignments on the \`bash\` side of the pipe. Use variable references in that script, never real credential values in argv. +Because the NemoClaw installer persists account files, use the explicitly approved \`account-home\` profile for that helper run. Pass \`--disable\` as the first argument to the fetching \`curl\` so it does not load a curl config; the account home and absolute working directory remain disclosed trusted boundaries for the installer-side \`bash\`. Do not offer the Hermes Provider option for OpenClaw or Deep Agents. For example, for an approved Local Ollama setup: diff --git a/docs/resources/local-credential-form.html b/docs/resources/local-credential-form.html index 11552596f2..98ca7a9f91 100644 --- a/docs/resources/local-credential-form.html +++ b/docs/resources/local-credential-form.html @@ -9,7 +9,7 @@ NemoClaw Local Credential Form @@ -160,6 +160,8 @@

NemoClaw Local Credential Form

/(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/i; const PROCESS_CONTROL_FIELD_NAMES = new Set([ "ALL_PROXY", + "ALLUSERSPROFILE", + "APPDATA", "AWS_CA_BUNDLE", "BASHOPTS", "BASH_ENV", @@ -167,11 +169,19 @@

NemoClaw Local Credential Form

"CLASSPATH", "COMSPEC", "CURL_CA_BUNDLE", + "CURL_HOME", "DENO_CERT", + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", "DOTNET_STARTUP_HOOKS", "ENV", "FTP_PROXY", "GIT_ASKPASS", + "GIT_COMMON_DIR", + "GIT_DIR", "GIT_EDITOR", "GIT_EXEC_PATH", "GIT_EXTERNAL_DIFF", @@ -185,15 +195,23 @@

NemoClaw Local Credential Form

"GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "GLOBIGNORE", + "GCONV_PATH", + "GLIBC_TUNABLES", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", "GRPC_PROXY", + "HOME", + "HOMEDRIVE", + "HOMEPATH", "HTTP_PROXY", "HTTPS_PROXY", "IFS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", + "KUBECONFIG", "LESSCLOSE", "LESSOPEN", + "LOCALAPPDATA", + "LOCPATH", "MANPAGER", "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", @@ -202,16 +220,39 @@

NemoClaw Local Credential Form

"NODE_USE_ENV_PROXY", "NODE_USE_SYSTEM_CA", "NO_PROXY", + "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", "PAGER", "PATH", "PATHEXT", "PERL5LIB", "PERL5OPT", "PS4", + "PWD", + "PSMODULEPATH", + "PROGRAMDATA", "PYTHONHOME", "PYTHONINSPECT", "PYTHONPATH", "PYTHONSTARTUP", + "PYTHONUSERBASE", "REQUESTS_CA_BUNDLE", "RUBYLIB", "RUBYOPT", @@ -222,6 +263,20 @@

NemoClaw Local Credential Form

"SSLKEYLOGFILE", "SSL_CERT_DIR", "SSL_CERT_FILE", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "VIRTUAL_ENV", + "XDG_CACHE_HOME", + "XDG_BIN_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", + "ZDOTDIR", "_JAVA_OPTIONS", ]); const params = new URLSearchParams(window.location.search); @@ -275,6 +330,7 @@

NemoClaw Local Credential Form

name.startsWith("GIT_TRACE") || name.startsWith("LD_") || name.startsWith("NPM_CONFIG_") || + name.startsWith("OPENSHELL_") || name.startsWith("PIP_") ); } diff --git a/scripts/local-credential-helper.mts b/scripts/local-credential-helper.mts index ffd662a8b4..d66fe5b50d 100755 --- a/scripts/local-credential-helper.mts +++ b/scripts/local-credential-helper.mts @@ -4,14 +4,26 @@ import { type ChildProcess, spawn } from "node:child_process"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; -import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; +import { + chmodSync, + closeSync, + constants, + fstatSync, + mkdirSync, + mkdtempSync, + openSync, + readSync, + rmSync, + statSync, +} from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; +import { userInfo } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const EXPECTED_LOCAL_CREDENTIAL_FORM_SHA256 = - "47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea"; // gitleaks:allow -- checked-in SHA-256 integrity pin + "5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8"; // gitleaks:allow -- checked-in SHA-256 integrity pin export const LOCAL_CREDENTIAL_HELPER_HOST = "127.0.0.1"; export const LOCAL_CREDENTIAL_FORM_PATH = "/local-credential-form.html"; @@ -36,6 +48,8 @@ export const CREDENTIAL_SHAPED_NAME_PATTERN = const FORBIDDEN_CHILD_ENV_NAMES = new Set([ "ALL_PROXY", + "ALLUSERSPROFILE", + "APPDATA", "AWS_CA_BUNDLE", "BASHOPTS", "BASH_ENV", @@ -43,11 +57,19 @@ const FORBIDDEN_CHILD_ENV_NAMES = new Set([ "CLASSPATH", "COMSPEC", "CURL_CA_BUNDLE", + "CURL_HOME", "DENO_CERT", + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", "DOTNET_STARTUP_HOOKS", "ENV", "FTP_PROXY", "GIT_ASKPASS", + "GIT_COMMON_DIR", + "GIT_DIR", "GIT_EDITOR", "GIT_EXEC_PATH", "GIT_EXTERNAL_DIFF", @@ -61,15 +83,23 @@ const FORBIDDEN_CHILD_ENV_NAMES = new Set([ "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "GLOBIGNORE", + "GCONV_PATH", + "GLIBC_TUNABLES", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", "GRPC_PROXY", + "HOME", + "HOMEDRIVE", + "HOMEPATH", "HTTP_PROXY", "HTTPS_PROXY", "IFS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", + "KUBECONFIG", "LESSCLOSE", "LESSOPEN", + "LOCALAPPDATA", + "LOCPATH", "MANPAGER", "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", @@ -78,16 +108,39 @@ const FORBIDDEN_CHILD_ENV_NAMES = new Set([ "NODE_USE_ENV_PROXY", "NODE_USE_SYSTEM_CA", "NO_PROXY", + "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", "PAGER", "PATH", "PATHEXT", "PERL5LIB", "PERL5OPT", "PS4", + "PWD", + "PSMODULEPATH", + "PROGRAMDATA", "PYTHONHOME", "PYTHONINSPECT", "PYTHONPATH", "PYTHONSTARTUP", + "PYTHONUSERBASE", "REQUESTS_CA_BUNDLE", "RUBYLIB", "RUBYOPT", @@ -98,10 +151,25 @@ const FORBIDDEN_CHILD_ENV_NAMES = new Set([ "SSLKEYLOGFILE", "SSL_CERT_DIR", "SSL_CERT_FILE", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "VIRTUAL_ENV", + "XDG_CACHE_HOME", + "XDG_BIN_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", + "ZDOTDIR", "_JAVA_OPTIONS", ]); export type CredentialFieldType = "secret" | "text"; +export type CredentialExecutionProfile = "account-home" | "isolated"; export type CredentialField = Readonly<{ name: string; @@ -109,7 +177,9 @@ export type CredentialField = Readonly<{ }>; export type LocalCredentialHelperCliOptions = Readonly<{ + commandCwd?: string; commandArgv: readonly string[]; + executionProfile: CredentialExecutionProfile; fields: readonly CredentialField[]; formPath: string; }>; @@ -157,10 +227,15 @@ export function isForbiddenChildEnvName(name: string): boolean { name.startsWith("GIT_CONFIG_") || name.startsWith("GIT_TRACE") || name.startsWith("NPM_CONFIG_") || + name.startsWith("OPENSHELL_") || name.startsWith("PIP_") ); } +function isForbiddenInheritedChildEnvName(name: string): boolean { + return name.startsWith("NEMOCLAW_") || isForbiddenChildEnvName(name); +} + export function sanitizeInheritedChildEnvironment( environment: NodeJS.ProcessEnv, approvedFieldNames: ReadonlySet, @@ -172,12 +247,130 @@ export function sanitizeInheritedChildEnvironment( value !== undefined && !approvedFieldNames.has(canonicalName) && !isCredentialShapedName(canonicalName) && - !isForbiddenChildEnvName(canonicalName) + !isForbiddenInheritedChildEnvName(canonicalName) ); }), ); } +function createPrivateExecutionRoot(): string { + let root = ""; + try { + root = mkdtempSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), ".credential-child-"), + ); + chmodSync(root, 0o700); + for (const relativePath of [ + ["appdata", "local"], + ["appdata", "roaming"], + ["cache"], + ["config"], + ["config-dirs"], + ["data"], + ["data-dirs"], + ["runtime"], + ["state"], + ["tmp"], + ]) { + mkdirSync(path.join(root, ...relativePath), { mode: 0o700, recursive: true }); + } + return root; + } catch (error) { + if (root) rmSync(root, { force: true, recursive: true }); + throw error; + } +} + +function privateExecutionEnvironment(root: string): NodeJS.ProcessEnv { + return { + APPDATA: path.join(root, "appdata", "roaming"), + CURL_HOME: path.join(root, "config"), + HOME: root, + LOCALAPPDATA: path.join(root, "appdata", "local"), + PWD: root, + TEMP: path.join(root, "tmp"), + TMP: path.join(root, "tmp"), + TMPDIR: path.join(root, "tmp"), + USERPROFILE: root, + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_DIRS: path.join(root, "config-dirs"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_DIRS: path.join(root, "data-dirs"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_RUNTIME_DIR: path.join(root, "runtime"), + XDG_STATE_HOME: path.join(root, "state"), + }; +} + +function accountHomeEnvironment(commandCwd: string): NodeJS.ProcessEnv & { HOME: string } { + const home = userInfo().homedir; + if (!home || !path.isAbsolute(home)) { + throw new Error( + "Could not resolve an absolute home directory from the operating system account", + ); + } + const environment: NodeJS.ProcessEnv & { HOME: string } = { + HOME: home, + PWD: commandCwd, + }; + if (process.platform !== "win32") return environment; + Object.assign(environment, { + APPDATA: path.join(home, "AppData", "Roaming"), + LOCALAPPDATA: path.join(home, "AppData", "Local"), + TEMP: path.join(home, "AppData", "Local", "Temp"), + TMP: path.join(home, "AppData", "Local", "Temp"), + TMPDIR: path.join(home, "AppData", "Local", "Temp"), + USERPROFILE: home, + }); + if (/^[A-Za-z]:[\\/]/.test(home)) { + environment.HOMEDRIVE = home.slice(0, 2); + environment.HOMEPATH = home.slice(2) || "\\"; + } + return environment; +} + +type ExecutionContext = Readonly<{ + cleanup?: () => void; + cwd: string; + environment: NodeJS.ProcessEnv; +}>; + +function createExecutionContext( + profile: CredentialExecutionProfile, + commandCwd: string | undefined, +): ExecutionContext { + if (profile !== "isolated" && profile !== "account-home") { + throw new Error("The execution profile must be isolated or account-home"); + } + if (profile === "account-home") { + if (!commandCwd || !path.isAbsolute(commandCwd)) { + throw new Error("The account-home execution profile requires an absolute --cwd path"); + } + let isDirectory = false; + try { + isDirectory = statSync(commandCwd).isDirectory(); + } catch { + // Report one stable fail-closed error for missing and unreadable paths. + } + if (!isDirectory) { + throw new Error("The account-home execution profile --cwd path must be a directory"); + } + return Object.freeze({ + cwd: commandCwd, + environment: Object.freeze(accountHomeEnvironment(commandCwd)), + }); + } + if (commandCwd !== undefined) { + throw new Error("The isolated execution profile does not accept --cwd"); + } + const root = createPrivateExecutionRoot(); + return Object.freeze({ + cleanup: () => rmSync(root, { force: true, maxRetries: 2, recursive: true }), + cwd: root, + environment: Object.freeze(privateExecutionEnvironment(root)), + }); +} + function validateApprovedCommandArgv(commandArgv: readonly string[]): void { if (commandArgv.length === 0 || commandArgv[0].length === 0) { throw new Error("An executable must follow the -- separator"); @@ -226,20 +419,43 @@ export function parseCliArguments(argv: readonly string[]): LocalCredentialHelpe let formPath = defaultFormPath(); let formPathSeen = false; + let commandCwd: string | undefined; + let executionProfile: CredentialExecutionProfile | undefined; const fields: CredentialField[] = []; const fieldNames = new Set(); + const optionNames = new Set(["--cwd", "--execution-profile", "--field", "--form"]); for (let index = 0; index < optionArgs.length; index += 1) { const option = optionArgs[index]; - if (option !== "--form" && option !== "--field") { + if (!optionNames.has(option)) { throw new Error(`Unknown option before --: ${option}`); } const value = optionArgs[index + 1]; - if (value === undefined || value === "--form" || value === "--field") { + if (value === undefined || optionNames.has(value)) { throw new Error(`${option} requires a value`); } index += 1; + if (option === "--execution-profile") { + if (executionProfile !== undefined) { + throw new Error("--execution-profile may be specified only once"); + } + if (value !== "isolated" && value !== "account-home") { + throw new Error("--execution-profile must be isolated or account-home"); + } + executionProfile = value; + continue; + } + + if (option === "--cwd") { + if (commandCwd !== undefined) throw new Error("--cwd may be specified only once"); + if (!path.isAbsolute(value) || value.includes("\0")) { + throw new Error("--cwd must be an absolute path without NUL bytes"); + } + commandCwd = value; + continue; + } + if (option === "--form") { if (formPathSeen) throw new Error("--form may be specified only once"); if (value.length === 0 || value.includes("\0")) { @@ -264,9 +480,20 @@ export function parseCliArguments(argv: readonly string[]): LocalCredentialHelpe if (fields.length === 0) { throw new Error("At least one --field NAME:secret or --field NAME:text is required"); } + if (executionProfile === undefined) { + throw new Error("--execution-profile isolated or --execution-profile account-home is required"); + } + if (executionProfile === "account-home" && commandCwd === undefined) { + throw new Error("--execution-profile account-home requires an absolute --cwd path"); + } + if (executionProfile === "isolated" && commandCwd !== undefined) { + throw new Error("--execution-profile isolated does not accept --cwd"); + } return Object.freeze({ + commandCwd, commandArgv: Object.freeze([...commandArgv]), + executionProfile, fields: Object.freeze([...fields]), formPath, }); @@ -535,7 +762,9 @@ function clearCredentialReferences( } export async function startLocalCredentialHelper(options: { + commandCwd?: string; commandArgv: readonly string[]; + executionProfile: CredentialExecutionProfile; fields: readonly CredentialField[]; formBytes: Buffer; timeoutMs?: number; @@ -577,7 +806,6 @@ export async function startLocalCredentialHelper(options: { const completion = new Promise((resolve) => { resolveCompletion = resolve; }); - const server = createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (request, response) => { void handleRequest(request, response).catch((error: unknown) => { request.resume(); @@ -593,6 +821,23 @@ export async function startLocalCredentialHelper(options: { server.keepAliveTimeout = 1_000; server.maxHeadersCount = 32; + const executionContext = createExecutionContext(options.executionProfile, options.commandCwd); + let executionContextCleaned = false; + const cleanupExecutionContext = (): void => { + if (executionContextCleaned || executionContext.cleanup === undefined) return; + executionContextCleaned = true; + try { + executionContext.cleanup(); + } catch { + console.error("Warning: could not remove the private approved-command directory."); + } + }; + process.once("exit", cleanupExecutionContext); + void completion.finally(() => { + process.off("exit", cleanupExecutionContext); + cleanupExecutionContext(); + }); + const finishWithoutChild = (nextState: "expired" | "closed", message: string): void => { if (state !== "pending") return; state = nextState; @@ -609,9 +854,14 @@ export async function startLocalCredentialHelper(options: { ); const launchApprovedCommand = (values: Record): void => { - const childEnv: NodeJS.ProcessEnv = { ...sanitizedAmbientEnv, ...values }; + const childEnv: NodeJS.ProcessEnv = { + ...sanitizedAmbientEnv, + ...executionContext.environment, + ...values, + }; try { child = spawn(commandArgv[0], commandArgv.slice(1), { + cwd: executionContext.cwd, env: childEnv, shell: false, stdio: "inherit", @@ -775,7 +1025,9 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro const options = parseCliArguments(argv); const formBytes = loadVerifiedCredentialForm(options.formPath); const session = await startLocalCredentialHelper({ + commandCwd: options.commandCwd, commandArgv: options.commandArgv, + executionProfile: options.executionProfile, fields: options.fields, formBytes, }); diff --git a/src/lib/security/process-control-env.test.ts b/src/lib/security/process-control-env.test.ts index 3c9f6f74bd..2ac4852049 100644 --- a/src/lib/security/process-control-env.test.ts +++ b/src/lib/security/process-control-env.test.ts @@ -20,13 +20,73 @@ describe("credential-handoff process-control environment policy", () => { "GIT_CONFIG_COUNT", "GIT_TRACE2_EVENT", "NPM_CONFIG_REGISTRY", + "OPENSHELL_DOCKER_SUPERVISOR_IMAGE", "PIP_INDEX_URL", ])("blocks the process-control rule for %s (#5048)", (name) => { expect(isProcessControlEnvName(name)).toBe(true); }); it.each([ + "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", + ])("blocks ambient configuration selector %s (#5048)", (name) => { + expect(isProcessControlEnvName(name)).toBe(true); + }); + + it.each([ "LANG", "PUBLIC_ID", "SAFE_SETTING", diff --git a/src/lib/security/process-control-env.ts b/src/lib/security/process-control-env.ts index b6c07e0ebe..64a2b63524 100644 --- a/src/lib/security/process-control-env.ts +++ b/src/lib/security/process-control-env.ts @@ -10,6 +10,8 @@ */ export const PROCESS_CONTROL_ENV_NAMES: ReadonlySet = new Set([ "ALL_PROXY", + "ALLUSERSPROFILE", + "APPDATA", "AWS_CA_BUNDLE", "BASHOPTS", "BASH_ENV", @@ -17,11 +19,19 @@ export const PROCESS_CONTROL_ENV_NAMES: ReadonlySet = new Set([ "CLASSPATH", "COMSPEC", "CURL_CA_BUNDLE", + "CURL_HOME", "DENO_CERT", + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", "DOTNET_STARTUP_HOOKS", "ENV", "FTP_PROXY", "GIT_ASKPASS", + "GIT_COMMON_DIR", + "GIT_DIR", "GIT_EDITOR", "GIT_EXEC_PATH", "GIT_EXTERNAL_DIFF", @@ -35,15 +45,23 @@ export const PROCESS_CONTROL_ENV_NAMES: ReadonlySet = new Set([ "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "GLOBIGNORE", + "GCONV_PATH", + "GLIBC_TUNABLES", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", "GRPC_PROXY", + "HOME", + "HOMEDRIVE", + "HOMEPATH", "HTTP_PROXY", "HTTPS_PROXY", "IFS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", + "KUBECONFIG", "LESSCLOSE", "LESSOPEN", + "LOCALAPPDATA", + "LOCPATH", "MANPAGER", "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", @@ -52,16 +70,39 @@ export const PROCESS_CONTROL_ENV_NAMES: ReadonlySet = new Set([ "NODE_USE_ENV_PROXY", "NODE_USE_SYSTEM_CA", "NO_PROXY", + "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", "PAGER", "PATH", "PATHEXT", "PERL5LIB", "PERL5OPT", "PS4", + "PWD", + "PSMODULEPATH", + "PROGRAMDATA", "PYTHONHOME", "PYTHONINSPECT", "PYTHONPATH", "PYTHONSTARTUP", + "PYTHONUSERBASE", "REQUESTS_CA_BUNDLE", "RUBYLIB", "RUBYOPT", @@ -72,6 +113,20 @@ export const PROCESS_CONTROL_ENV_NAMES: ReadonlySet = new Set([ "SSLKEYLOGFILE", "SSL_CERT_DIR", "SSL_CERT_FILE", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "VIRTUAL_ENV", + "XDG_CACHE_HOME", + "XDG_BIN_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", + "ZDOTDIR", "_JAVA_OPTIONS", ]); @@ -85,6 +140,7 @@ export function isProcessControlEnvName(name: string): boolean { name.startsWith("GIT_CONFIG_") || name.startsWith("GIT_TRACE") || name.startsWith("NPM_CONFIG_") || + name.startsWith("OPENSHELL_") || name.startsWith("PIP_") ); } diff --git a/test/local-credential-helper-pin.test.ts b/test/local-credential-helper-pin.test.ts index c05e10fa8b..15a7d5cba6 100644 --- a/test/local-credential-helper-pin.test.ts +++ b/test/local-credential-helper-pin.test.ts @@ -23,6 +23,7 @@ const VALID_ATOMS = [ 'name.startsWith("GIT_CONFIG_")', 'name.startsWith("GIT_TRACE")', 'name.startsWith("NPM_CONFIG_")', + 'name.startsWith("OPENSHELL_")', 'name.startsWith("PIP_")', ]; const EXPECTED_RULES = [ @@ -34,6 +35,7 @@ const EXPECTED_RULES = [ "prefix:GIT_TRACE", "prefix:LD_", "prefix:NPM_CONFIG_", + "prefix:OPENSHELL_", "prefix:PIP_", ]; @@ -54,6 +56,7 @@ const PARITY_PREFIXES = [ "GIT_TRACE", "LD_", "NPM_CONFIG_", + "OPENSHELL_", "PIP_", ]; @@ -174,7 +177,8 @@ describe("local credential helper pin predicate parity", () => { { 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: "PIP_ 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(" || "); diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts index e1a6953862..dfa9df9346 100644 --- a/test/local-credential-helper.test.ts +++ b/test/local-credential-helper.test.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { buildCredentialFormCsp, + parseCredentialField, sanitizeInheritedChildEnvironment, startLocalCredentialHelper, } from "../scripts/local-credential-helper.mts"; @@ -24,6 +25,84 @@ const READINESS_URL_PATTERN = /http:\/\/127\.0\.0\.1:\d+\/\S*#cap=[A-Za-z0-9_-]{ 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", @@ -78,6 +157,8 @@ const BLOCKED_INHERITED_ENV_NAMES = [ "Gh_ToKeN", "NODE_OPTIONS", "Node_Options", + "NEMOCLAW_PROVIDER", + "OPENSHELL_DOCKER_SUPERVISOR_IMAGE", "PATH", "Path", "UNRELATED_API_TOKEN", @@ -118,6 +199,13 @@ type ReadinessState = { kind: "exited" } | { kind: "ready"; url: string } | { ki 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(); @@ -125,9 +213,13 @@ afterEach(async () => { tempDirs.clear(); }); -function captureChild(args: string[], envOverrides: NodeJS.ProcessEnv = {}): CapturedChild { +function captureChild( + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, + cwd = REPO_ROOT, +): CapturedChild { const child = spawn(process.execPath, args, { - cwd: REPO_ROOT, + cwd, env: { ...process.env, ...envOverrides, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"], }); @@ -188,16 +280,23 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str } } -function helperArgs(fields: string[], command: string[], formPath = FORM_PATH): string[] { - return [ +function helperArgs( + fields: string[], + command: string[], + formPath = FORM_PATH, + executionProfile: "account-home" | "isolated" = "isolated", + commandCwd?: string, +): string[] { + const args = [ "--experimental-strip-types", HELPER_PATH, + "--execution-profile", + executionProfile, "--form", formPath, - ...fields.flatMap((field) => ["--field", field]), - "--", - ...command, ]; + if (commandCwd !== undefined) args.push("--cwd", commandCwd); + return [...args, ...fields.flatMap((field) => ["--field", field]), "--", ...command]; } function readinessState(captured: CapturedChild): ReadinessState { @@ -232,10 +331,20 @@ async function waitForReadiness(captured: CapturedChild): Promise { 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), + 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)); @@ -257,6 +366,7 @@ function createCommandFixture(): { 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");', @@ -265,6 +375,14 @@ function createCommandFixture(): { `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(""); @@ -372,6 +490,11 @@ async function expectSuccessfulCompletion( 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`; + if (fs.existsSync(privateRootMarker)) { + const privateRoot = fs.readFileSync(privateRootMarker, "utf8"); + expect(fs.existsSync(privateRoot)).toBe(false); + } } describe("local credential helper", () => { @@ -384,6 +507,7 @@ describe("local credential helper", () => { it("sanitizes inherited names case-insensitively without mutating the source environment (#5048)", () => { const ambient = { + ...CONFIG_ROOT_ENV_OVERRIDES, ...NETWORK_TRUST_ENV_OVERRIDES, ...PACKAGE_CONTROL_ENV_OVERRIDES, BASH_ENV: "shell-hook", @@ -398,8 +522,8 @@ describe("local credential helper", () => { GIT_PROXY_COMMAND: "/ambient/proxy-wrapper", GIT_TRACE2_EVENT: "/ambient/git-trace.json", GIT_SSH: "/ambient/ssh-wrapper", - HOME: "/safe/home", LD_PRELOAD: "loader-hook", + NEMOCLAW_PROVIDER: "ambient-provider", Node_Options: "--no-warnings", Path: "/ambient/search/path", Public_Id: "ambient-field-collision", @@ -410,10 +534,23 @@ describe("local credential helper", () => { const sanitized = sanitizeInheritedChildEnvironment(ambient, new Set(["PUBLIC_ID"])); - expect(sanitized).toEqual({ HOME: "/safe/home", SAFE_SETTING: "preserved" }); + expect(sanitized).toEqual({ SAFE_SETTING: "preserved" }); 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"], @@ -432,11 +569,19 @@ describe("local credential helper", () => { { 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"], @@ -473,16 +618,129 @@ describe("local credential helper", () => { 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, + }); + + const exitCode = await session.completion; + if (exitCode !== 1) { + throw new Error(`expected an expired credential session to exit 1, received ${exitCode}`); + } + 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", + }); + + if (accepted.status !== 202) { + throw new Error( + `expected the credential submission to be accepted, received ${accepted.status}`, + ); + } + const exitCode = await session.completion; + if (exitCode !== 1) { + throw new Error(`expected a failed credential child spawn to exit 1, received ${exitCode}`); + } + 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); @@ -539,29 +797,55 @@ describe("local credential helper", () => { it("strips inherited credentials and process controls before launching the approved command (#5048)", async () => { const fixture = createCommandFixture(); - const helper = await startHelper(fixture.command, { - ...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", - 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", - }); + 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(), @@ -574,7 +858,63 @@ describe("local credential helper", () => { await expectSuccessfulCompletion(helper, fixture.markerPath); }); - it("preserves explicit approved proxy and CA arguments byte-for-byte (#5048)", async () => { + 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 expectedEnvironment: Record = { + HOME: accountHome, + PWD: commandCwd, + }; + if (process.platform === "win32") { + const accountTemp = path.join(accountHome, "AppData", "Local", "Temp"); + Object.assign(expectedEnvironment, { + APPDATA: path.join(accountHome, "AppData", "Roaming"), + LOCALAPPDATA: path.join(accountHome, "AppData", "Local"), + TEMP: accountTemp, + TMP: accountTemp, + TMPDIR: accountTemp, + USERPROFILE: accountHome, + }); + if (/^[A-Za-z]:[\\/]/.test(accountHome)) { + expectedEnvironment.HOMEDRIVE = accountHome.slice(0, 2); + expectedEnvironment.HOMEPATH = accountHome.slice(2) || "\\"; + } + } + 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"); @@ -585,6 +925,10 @@ describe("local credential helper", () => { "127.0.0.1", "--cacert", "/trusted/ca.pem", + "--config", + "/trusted/client.conf", + "--profile", + "approved-profile", ]; const command = [ process.execPath, diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index e642d12651..df93825b0f 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -28,16 +28,16 @@ const localCredentialFormSource = path.join( const localCredentialHelperUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/scripts/local-credential-helper.mts"; const localCredentialHelperSha256 = - "eb8f14939fbea3feb56bb6b90181ce7e59549c434d528bf7d371c8432597bbcc"; // gitleaks:allow -- checked-in SHA-256 fixture + "04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = - "47fba6db8b1203a1761fc7fd164196ad543aad9cdd9ba5a5a4657c67dbe266ea"; // gitleaks:allow -- checked-in SHA-256 fixture + "5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ - "'sha256-+FpZq5OW", - "1xPb3W3TaGBnIC3M", - "nIL4tg1d+ZnMOwI6", - "Pko='", + "'sha256-i3cXmSMU", + "jTA5LqLSfFQpXe0B", + "BZRj4cM8t36dJMm3", + "YJw='", ].join(""); const localCredentialFormStyleCspHash = [ "'sha256-W4wSJyrm", @@ -70,6 +70,63 @@ const localCredentialNetworkControlNames = [ "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", @@ -377,11 +434,14 @@ describe("starter prompt docs CTA", () => { ); 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("absolute native executable or interpreter path"); - expect(promptSource).toContain("including proxy-routing and TLS-trust overrides"); - expect(promptSource).toContain("ambient \\`NPM_CONFIG_*\\` and \\`PIP_*\\`"); - expect(promptSource).toContain("package registry, or Python package index"); - expect(promptSource).toContain("never put proxy credentials in argv"); + expect(promptSource).toContain("including configuration roots, source/executable selectors"); + expect(promptSource).toContain("\\`NPM_CONFIG_*\\` and \\`PIP_*\\`"); + expect(promptSource).toContain("account home and the approved working directory"); + expect(promptSource).toContain("\\`--disable\\` as the first argument"); + expect(promptSource).toContain("Never put credentials in argv"); expect(promptSource).toContain(" -c"); expect(promptSource).toContain("Confirm and Run Approved Command"); expect(promptSource).toContain("do not retry or resubmit"); @@ -468,7 +528,11 @@ describe("starter prompt docs CTA", () => { ...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", ]) { From 9254855ff7dacf75cea3d4b1505f7900dd56cf02 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 22:43:01 -0700 Subject: [PATCH 14/20] docs(security): repin isolated credential artifacts Signed-off-by: Carlos Villela --- docs/_components/StarterPrompt.tsx | 4 ++-- test/starter-prompt-docs.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 26d12b476b..57569a5324 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,8 +93,8 @@ When you need an API key, bot token, app token, or other secret, prefer the chec - Before starting credential collection, determine 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: - - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/scripts/local-credential-helper.mts (SHA-256 \`04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85\`) - - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/docs/resources/local-credential-form.html (SHA-256 \`5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8\`) + - Helper: https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/scripts/local-credential-helper.mts (SHA-256 \`04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85\`) + - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/docs/resources/local-credential-form.html (SHA-256 \`5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8\`) - Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. - 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. - 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 helper or form code. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index df93825b0f..c232d1cc7e 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,11 +26,11 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialHelperUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/scripts/local-credential-helper.mts"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/scripts/local-credential-helper.mts"; const localCredentialHelperSha256 = "04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/f393bba599444c5b031b1ba372228bd007176055/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = "5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ From 78867a31799359fef2127252aaff3259148f86e4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 22:48:00 -0700 Subject: [PATCH 15/20] test(security): keep credential helper tests linear Signed-off-by: Carlos Villela --- test/local-credential-helper.test.ts | 74 ++++++++++++++-------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts index dfa9df9346..e78aaef19e 100644 --- a/test/local-credential-helper.test.ts +++ b/test/local-credential-helper.test.ts @@ -287,6 +287,7 @@ function helperArgs( executionProfile: "account-home" | "isolated" = "isolated", commandCwd?: string, ): string[] { + const cwdArgs = commandCwd === undefined ? [] : ["--cwd", commandCwd]; const args = [ "--experimental-strip-types", HELPER_PATH, @@ -294,8 +295,8 @@ function helperArgs( executionProfile, "--form", formPath, + ...cwdArgs, ]; - if (commandCwd !== undefined) args.push("--cwd", commandCwd); return [...args, ...fields.flatMap((field) => ["--field", field]), "--", ...command]; } @@ -475,6 +476,14 @@ function expectRejected(result: HttpResult): void { 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; @@ -491,10 +500,9 @@ async function expectSuccessfulCompletion( expect(result).toEqual({ code: 0, signal: null }); expect(commandRunCount(markerPath)).toBe(1); const privateRootMarker = `${markerPath}.private-root`; - if (fs.existsSync(privateRootMarker)) { - const privateRoot = fs.readFileSync(privateRootMarker, "utf8"); - expect(fs.existsSync(privateRoot)).toBe(false); - } + const privateRootStillExists = + fs.existsSync(privateRootMarker) && fs.existsSync(fs.readFileSync(privateRootMarker, "utf8")); + expect(privateRootStillExists).toBe(false); } describe("local credential helper", () => { @@ -694,10 +702,7 @@ describe("local credential helper", () => { timeoutMs: 20, }); - const exitCode = await session.completion; - if (exitCode !== 1) { - throw new Error(`expected an expired credential session to exit 1, received ${exitCode}`); - } + await expectCompletionCode(session.completion, 1); expect(privateExecutionRoots()).toEqual(rootsBefore); }); @@ -729,15 +734,8 @@ describe("local credential helper", () => { path: "/submit", }); - if (accepted.status !== 202) { - throw new Error( - `expected the credential submission to be accepted, received ${accepted.status}`, - ); - } - const exitCode = await session.completion; - if (exitCode !== 1) { - throw new Error(`expected a failed credential child spawn to exit 1, received ${exitCode}`); - } + expectHttpStatus(accepted, 202); + await expectCompletionCode(session.completion, 1); expect(privateExecutionRoots()).toEqual(rootsBefore); }); @@ -889,25 +887,27 @@ describe("local credential helper", () => { environment: Record; }; const accountHome = os.userInfo().homedir; - const expectedEnvironment: Record = { - HOME: accountHome, - PWD: commandCwd, - }; - if (process.platform === "win32") { - const accountTemp = path.join(accountHome, "AppData", "Local", "Temp"); - Object.assign(expectedEnvironment, { - APPDATA: path.join(accountHome, "AppData", "Roaming"), - LOCALAPPDATA: path.join(accountHome, "AppData", "Local"), - TEMP: accountTemp, - TMP: accountTemp, - TMPDIR: accountTemp, - USERPROFILE: accountHome, - }); - if (/^[A-Za-z]:[\\/]/.test(accountHome)) { - expectedEnvironment.HOMEDRIVE = accountHome.slice(0, 2); - expectedEnvironment.HOMEPATH = accountHome.slice(2) || "\\"; - } - } + 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)), From a413ba3438c2390e8dc764a077bb565f3456f8fb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Jul 2026 22:51:39 -0700 Subject: [PATCH 16/20] test(security): clarify generated cleanup marker Signed-off-by: Carlos Villela --- test/local-credential-helper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts index e78aaef19e..7795cee83f 100644 --- a/test/local-credential-helper.test.ts +++ b/test/local-credential-helper.test.ts @@ -383,7 +383,7 @@ function createCommandFixture(): { "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.writeFileSync(markerPath + ".private-root", privateRoot);', 'fs.appendFileSync(markerPath, "ran\\n");', "if (fs.existsSync(unexpectedShellPath)) process.exit(24);", ].join(""); From 22f6ac305cae25a9b9ae998e4907af473edc4ac5 Mon Sep 17 00:00:00 2001 From: Miyoung Choi Date: Wed, 8 Jul 2026 11:28:34 -0700 Subject: [PATCH 17/20] docs(starter-prompt): slim credential capture guidance (#6477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stacked on top of #6439. Keeps the hardened credential helper and form from that PR, but slims the copy-paste starter prompt so the default "non-technical user" onboarding path stays easy. The prompt no longer asks the coding agent to synthesize execution-profile prose, per-tool env-strip reasoning, or a `curl | bash` credential wrapper. ## Related Issue Follow-up to #6439. Merge #6439 first, then this on top. ## Changes - Slim the "Handle Tokens Securely and Visually" section of `STARTER_PROMPT` from ~18 dense bullets to 10, keeping the pinned helper/form URLs and SHA-256 digests, one canonical `--execution-profile isolated` recipe, and the core safety rules (verify digests, one-shot confirm-then-run, redacted preview, no secrets in chat or argv, exposure-minimization). - Drop the prose the agent had to assemble itself: the `NPM_CONFIG_*`/`PIP_*` env-strip enumeration, Windows `.cmd` handling, and the `bash -c` non-exported-variable `curl | bash` wrapper. Compress `account-home` to one line and prefer the installer's own secure credential prompt for install-time secrets. - Update `test/starter-prompt-docs.test.ts` to match, and add `not.toContain` regression guards so the wrapper synthesis prose cannot creep back into the copied prompt. The hardened helper still enforces loopback-only access, one-shot submission, absolute executables, and ambient env stripping, so the slimmer prompt keeps the same security posture with far less procedural reasoning for the agent. Helper and form bytes are unchanged, so the pinned digests and commit stay valid. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Docs updated for user-facing behavior changes - [ ] Docs not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [x] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: prompt-only slimming; no change to helper/form bytes or their enforcement, `scripts/checks/local-credential-helper-pin.ts` passes, and this is intended to be reviewed on top of #6439. - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## Verification - [x] PR description includes the DCO sign-off declaration and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run check:diff` passed when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set — `npx vitest run --project integration test/starter-prompt-docs.test.ts test/local-credential-helper-pin.test.ts` passed 42/42, and `node --experimental-strip-types scripts/checks/local-credential-helper-pin.ts` reported pins immutable and current. - [ ] Applicable broad gate passed — not run; narrow starter-prompt and single-test change covered by the targeted tests and the pin check above. - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) — not run; change is prose inside the existing `STARTER_PROMPT` template literal, validated by the TS-AST pin check and starter-prompt tests. - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Miyoung Choi Signed-off-by: Miyoung Choi --- docs/_components/StarterPrompt.tsx | 32 +++++++++++------------------- test/starter-prompt-docs.test.ts | 14 ++++++------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 57569a5324..25bba552a0 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -89,32 +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 helper and form instead of chat. +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 starting credential collection, determine 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: +- 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/786856661c24aa25c0843bfe1e5bd72f21e927a2/scripts/local-credential-helper.mts (SHA-256 \`04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85\`) - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/docs/resources/local-credential-form.html (SHA-256 \`5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8\`) -- Fetch both files, or use local repository copies when available, and verify both SHA-256 digests before use. Put fetched copies in a private temporary directory with access restricted to the current user. -- 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. -- 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 helper or form code. -- Use one explicit execution profile. Choose \`--execution-profile isolated\` for stateless commands; it runs from a private temporary home and working directory that is deleted after the child exits. Choose \`--execution-profile account-home --cwd \` only when the command must persist account state, such as a NemoClaw install or onboarding run. Explain that account-home mode trusts existing configuration under the operating-system account home and the approved working directory, and ask my permission for both paths. -- Use the helper's repeated \`--field NAME:type\` arguments, followed by a literal \`--\` and the exact approved argv. For example: -- Resolve the approved executable to an absolute native executable or interpreter path before asking permission. The helper rejects PATH-based executable lookup and removes recognized ambient credential-shaped and process-control variables, including configuration roots, source/executable selectors, proxy-routing and TLS-trust overrides, and \`NPM_CONFIG_*\` and \`PIP_*\` package-source settings before applying the selected execution profile and overlaying only the fields collected for the approved command. Approve explicit paths for nested tools when the command needs a non-default PATH. If the command needs a trusted proxy, custom CA, package registry, Python package index, project directory, config file, or profile, select non-secret settings and pre-existing trusted paths in the exact approved argv using the tool's explicit flags. Never put credentials in argv. Use a pre-existing trusted absolute wrapper when the tool requires authenticated configuration or profile discovery that cannot be selected safely through argv. On native Windows, do not target a \`.cmd\` or \`.bat\` file directly; use the absolute \`cmd.exe\` path and make its arguments part of the approved argv. +- 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. Use \`:text\` only for non-secret IDs, allowlists, endpoint URLs, model names, and sandbox names. The helper must reject credential-looking \`:text\` fields and process-control environment names. -- Keep real credentials out of argv. The helper supplies accepted values only through the child process environment and invokes the frozen argv without adding a shell. -- Open only the one-time \`http://127.0.0.1\` URL printed by the helper in the coding-agent UI's browser. Do not paste that URL or its fragment capability into chat. -- In the form, enter the values and choose **Preview Credentials**. Preview is local-only: it clears the inputs and shows a redacted summary without sending a request. Choose **Edit** to discard that snapshot and re-enter every value. -- Choose **Confirm and Run Approved Command** only after the redacted summary matches the command I already approved. That click makes the form's sole credential-bearing request; the helper accepts at most one valid submission, closes its listener, and starts the exact approved argv. -- If the form says the outcome is unknown, do not retry or resubmit. Inspect the coding-agent terminal to determine whether the command ran, then start a fresh helper session only if needed. -- Keep submitted secrets only in memory long enough to start the approved command. Do not print them, write them to logs, commit them, or paste them into chat. -- Treat in-memory handling as exposure minimization, not guaranteed erasure. The helper wipes mutable request buffers and promptly drops its JavaScript references, but Node.js strings and the approved child process environment cannot be reliably zeroed. -- After the command finishes, delete any fetched helper and form copies and their private temporary directory. +- 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: @@ -130,8 +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\` | -For a credentialed \`curl | bash\` install, make the exact approved argv invoke \` -c\` with a script that copies each credential into a non-exported shell variable, unsets the exported credential before starting \`curl\`, and supplies it only in the environment assignments on the \`bash\` side of the pipe. Use variable references in that script, never real credential values in argv. -Because the NemoClaw installer persists account files, use the explicitly approved \`account-home\` profile for that helper run. Pass \`--disable\` as the first argument to the fetching \`curl\` so it does not load a curl config; the account home and absolute working directory remain disclosed trusted boundaries for the installer-side \`bash\`. +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/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index c232d1cc7e..81f758bb39 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -436,17 +436,17 @@ describe("starter prompt docs CTA", () => { expect(promptSource).toContain("--field NAME:type"); expect(promptSource).toContain("--execution-profile isolated"); expect(promptSource).toContain("--execution-profile account-home --cwd"); - expect(promptSource).toContain("absolute native executable or interpreter path"); - expect(promptSource).toContain("including configuration roots, source/executable selectors"); - expect(promptSource).toContain("\\`NPM_CONFIG_*\\` and \\`PIP_*\\`"); - expect(promptSource).toContain("account home and the approved working directory"); - expect(promptSource).toContain("\\`--disable\\` as the first argument"); expect(promptSource).toContain("Never put credentials in argv"); - expect(promptSource).toContain(" -c"); 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("unsets the exported credential before starting \\`curl\\`"); + 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';"); From dd61a307d7ddf7be99de8ff1e2678fb8ef42f8e6 Mon Sep 17 00:00:00 2001 From: cjagwani Date: Wed, 8 Jul 2026 19:48:46 +0000 Subject: [PATCH 18/20] fix(security): drop ambient credential child environment Signed-off-by: cjagwani --- scripts/local-credential-helper.mts | 22 +++------- test/local-credential-helper.test.ts | 61 ++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/scripts/local-credential-helper.mts b/scripts/local-credential-helper.mts index d66fe5b50d..3e4e60e2c8 100755 --- a/scripts/local-credential-helper.mts +++ b/scripts/local-credential-helper.mts @@ -232,25 +232,13 @@ export function isForbiddenChildEnvName(name: string): boolean { ); } -function isForbiddenInheritedChildEnvName(name: string): boolean { - return name.startsWith("NEMOCLAW_") || isForbiddenChildEnvName(name); -} - export function sanitizeInheritedChildEnvironment( - environment: NodeJS.ProcessEnv, - approvedFieldNames: ReadonlySet, + _environment: NodeJS.ProcessEnv, + _approvedFieldNames: ReadonlySet, ): NodeJS.ProcessEnv { - return Object.fromEntries( - Object.entries(environment).filter(([name, value]) => { - const canonicalName = name.toUpperCase(); - return ( - value !== undefined && - !approvedFieldNames.has(canonicalName) && - !isCredentialShapedName(canonicalName) && - !isForbiddenInheritedChildEnvName(canonicalName) - ); - }), - ); + // Unknown variables can be tool-specific execution controls. The child gets + // only the selected profile environment and explicitly submitted fields. + return {}; } function createPrivateExecutionRoot(): string { diff --git a/test/local-credential-helper.test.ts b/test/local-credential-helper.test.ts index 7795cee83f..b942ca664a 100644 --- a/test/local-credential-helper.test.ts +++ b/test/local-credential-helper.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { type ChildProcess, spawn } from "node:child_process"; +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"; @@ -513,7 +513,7 @@ describe("local credential helper", () => { expect(csp).toContain("style-src 'sha256-"); }); - it("sanitizes inherited names case-insensitively without mutating the source environment (#5048)", () => { + it("drops every ambient entry without mutating the source environment (#5048)", () => { const ambient = { ...CONFIG_ROOT_ENV_OVERRIDES, ...NETWORK_TRUST_ENV_OVERRIDES, @@ -535,14 +535,14 @@ describe("local credential helper", () => { Node_Options: "--no-warnings", Path: "/ambient/search/path", Public_Id: "ambient-field-collision", - SAFE_SETTING: "preserved", + 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({ SAFE_SETTING: "preserved" }); + expect(sanitized).toEqual({}); expect(ambient).toEqual(original); }); @@ -856,6 +856,59 @@ describe("local credential helper", () => { 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); From e5be9d587439330f07e5a9bac86dcff26c6122a3 Mon Sep 17 00:00:00 2001 From: cjagwani Date: Wed, 8 Jul 2026 19:49:47 +0000 Subject: [PATCH 19/20] docs(security): repin local credential artifacts Signed-off-by: cjagwani --- docs/_components/StarterPrompt.tsx | 4 ++-- test/starter-prompt-docs.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/_components/StarterPrompt.tsx b/docs/_components/StarterPrompt.tsx index 25bba552a0..a429fd56a6 100644 --- a/docs/_components/StarterPrompt.tsx +++ b/docs/_components/StarterPrompt.tsx @@ -93,8 +93,8 @@ When you need an API key, bot token, app token, or other secret, use the checked - 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/786856661c24aa25c0843bfe1e5bd72f21e927a2/scripts/local-credential-helper.mts (SHA-256 \`04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85\`) - - Form: https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/docs/resources/local-credential-form.html (SHA-256 \`5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8\`) + - 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: diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 81f758bb39..8da752b2a9 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -26,11 +26,11 @@ const localCredentialFormSource = path.join( "local-credential-form.html", ); const localCredentialHelperUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/scripts/local-credential-helper.mts"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/dd61a307d7ddf7be99de8ff1e2678fb8ef42f8e6/scripts/local-credential-helper.mts"; const localCredentialHelperSha256 = - "04cd84bf261635cd483669f198671636e38da423fbef059d7d052824879a5f85"; // gitleaks:allow -- checked-in SHA-256 fixture + "1a42bbe8dbc9003cb79d4e641b53760571aacd85293671aee97c09c0746fef33"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormUrl = - "https://raw.githubusercontent.com/NVIDIA/NemoClaw/786856661c24aa25c0843bfe1e5bd72f21e927a2/docs/resources/local-credential-form.html"; + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/dd61a307d7ddf7be99de8ff1e2678fb8ef42f8e6/docs/resources/local-credential-form.html"; const localCredentialFormSha256 = "5512a256e0ad7c63a26ab82cf4f5924e98652097172ab8a5dc9d9358dd4f6ae8"; // gitleaks:allow -- checked-in SHA-256 fixture const localCredentialFormScriptCspHash = [ From ec50830c531e9567a9ce2bc809d8bf93fe2f3afe Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 8 Jul 2026 13:13:17 -0700 Subject: [PATCH 20/20] test(dcode): keep image suite within budget Signed-off-by: Carlos Villela --- test/langchain-deepagents-code-image.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 3ce2dad341..fc3ec6bb22 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -694,7 +694,6 @@ describe("LangChain Deep Agents Code image contracts", () => { it("ships a headless inference acceptance check for Deep Agents Code", () => { const headlessCheck = fs.readFileSync(headlessCheckPath, "utf8"); - for (const expected of [ 'sandbox_exec "test -d /sandbox/.deepagents"', "command -v dcode",