diff --git a/src/lib/adapters/http/probe.ts b/src/lib/adapters/http/probe.ts index 5c6046bc66..a4599ecf7b 100644 --- a/src/lib/adapters/http/probe.ts +++ b/src/lib/adapters/http/probe.ts @@ -35,14 +35,32 @@ export interface StreamingProbeResult { message: string; } +function validateTempPrefix(prefix: string): string { + if ( + prefix.length === 0 || + prefix !== path.basename(prefix) || + prefix.includes(path.posix.sep) || + prefix.includes(path.win32.sep) + ) { + throw new Error(`Invalid temp file prefix: ${prefix}`); + } + return prefix; +} + function secureTempFile(prefix: string, ext = ""): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - return path.join(dir, `${prefix}${ext}`); + const safePrefix = validateTempPrefix(prefix); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${safePrefix}-`)); + return path.join(dir, `${safePrefix}${ext}`); } function cleanupTempDir(filePath: string, expectedPrefix: string): void { - const parentDir = path.dirname(filePath); - if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { + const safePrefix = validateTempPrefix(expectedPrefix); + const tempRoot = path.resolve(os.tmpdir()); + const parentDir = path.resolve(path.dirname(filePath)); + const relativeParent = path.relative(tempRoot, parentDir); + const isInsideTempRoot = + relativeParent !== "" && !relativeParent.startsWith("..") && !path.isAbsolute(relativeParent); + if (isInsideTempRoot && path.basename(parentDir).startsWith(`${safePrefix}-`)) { fs.rmSync(parentDir, { recursive: true, force: true }); } } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3aedb345a0..d9f9da9ffc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -16,6 +16,15 @@ const { cliName, setOnboardBrandingAgent, }: typeof import("./onboard/branding") = require("./onboard/branding"); +const { + cleanupTempDir, + secureTempFile, +}: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); +const { + buildDirectGpuPolicyYaml, + buildDirectSandboxGpuProofCommands, + prepareInitialSandboxCreatePolicy, +}: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -323,28 +332,6 @@ import type { SandboxCreateFailure, ValidationClassification } from "./validatio import type { ProbeRecovery } from "./validation-recovery"; import type { WebSearchConfig } from "./inference/web-search"; -/** - * Create a temp file inside a directory with a cryptographically random name. - * Uses fs.mkdtempSync (OS-level mkdtemp) to avoid predictable filenames that - * could be exploited via symlink attacks on shared /tmp. - * Ref: https://github.com/NVIDIA/NemoClaw/issues/1093 - */ -function secureTempFile(prefix: string, ext = ""): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - return path.join(dir, `${prefix}${ext}`); -} - -/** - * Safely remove a mkdtemp-created directory. Guards against accidentally - * deleting the system temp root if a caller passes os.tmpdir() itself. - */ -function cleanupTempDir(filePath: string, expectedPrefix: string): void { - const parentDir = path.dirname(filePath); - if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { - fs.rmSync(parentDir, { recursive: true, force: true }); - } -} - const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; @@ -2102,79 +2089,6 @@ type SelectionDrift = { unknown: boolean; }; -type InitialSandboxPolicy = { - policyPath: string; - appliedPresets: string[]; - cleanup?: () => boolean; -}; - -const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record = { - slack: ["slack"], -}; - -const PROC_COMM_READ_WRITE_PATH = "/proc/self/task/*/comm"; - -function buildDirectGpuPolicyYaml(basePolicy: string): string { - const YAML = require("yaml"); - const parsed = YAML.parse(basePolicy); - if (!parsed || typeof parsed !== "object") { - throw new Error("Cannot prepare direct GPU sandbox policy; base policy is not a YAML mapping."); - } - parsed.filesystem_policy = parsed.filesystem_policy || {}; - const fsPolicy = parsed.filesystem_policy; - fsPolicy.read_only = Array.isArray(fsPolicy.read_only) - ? fsPolicy.read_only.map((entry: unknown) => String(entry)) - : []; - if (!fsPolicy.read_only.includes("/proc")) { - fsPolicy.read_only.push("/proc"); - } - const readWrite = Array.isArray(fsPolicy.read_write) - ? fsPolicy.read_write.map((entry: unknown) => String(entry)) - : []; - fsPolicy.read_write = readWrite.filter((entry: string) => entry !== "/proc"); - if (!fsPolicy.read_write.includes(PROC_COMM_READ_WRITE_PATH)) { - fsPolicy.read_write.push(PROC_COMM_READ_WRITE_PATH); - } - return YAML.stringify(parsed); -} - -const PROC_COMM_WRITE_PROBE = ` -set -eu -tid="$(ls /proc/self/task | head -n 1)" -old="$(cat "/proc/self/task/\${tid}/comm" 2>/dev/null || true)" -printf nemoclaw-gpu >"/proc/self/task/\${tid}/comm" -if [ -n "$old" ]; then printf "%s" "$old" >"/proc/self/task/\${tid}/comm" || true; fi -`; - -const CUDA_INIT_PROBE = ` -python3 - <<'PY' -import ctypes -lib = ctypes.CDLL("libcuda.so.1") -rc = lib.cuInit(0) -print(f"cuInit(0)={rc}") -raise SystemExit(0 if rc == 0 else 1) -PY -`; - -function buildDirectSandboxGpuProofCommands( - sandboxName: string, -): { label: string; args: string[] }[] { - return [ - { - label: "nvidia-smi", - args: ["sandbox", "exec", "-n", sandboxName, "--", "nvidia-smi"], - }, - { - label: "/proc/self/task//comm write", - args: ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", PROC_COMM_WRITE_PROBE], - }, - { - label: "cuInit(0) via libcuda.so.1", - args: ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", CUDA_INIT_PROBE], - }, - ]; -} - function verifyDirectSandboxGpu(sandboxName: string): void { console.log(" Verifying direct sandbox GPU access..."); for (const proof of buildDirectSandboxGpuProofCommands(sandboxName)) { @@ -2199,120 +2113,6 @@ function verifyDirectSandboxGpu(sandboxName: string): void { } } -function prepareDirectGpuSandboxPolicy(basePolicyPath: string): InitialSandboxPolicy { - const basePolicy = fs.readFileSync(basePolicyPath, "utf-8"); - const policyPath = secureTempFile("nemoclaw-gpu-policy", ".yaml"); - fs.writeFileSync(policyPath, buildDirectGpuPolicyYaml(basePolicy), { - encoding: "utf-8", - mode: 0o600, - }); - return { - policyPath, - appliedPresets: [], - cleanup: () => { - try { - cleanupTempDir(policyPath, "nemoclaw-gpu-policy"); - return true; - } catch { - return false; - } - }, - }; -} - -function getNetworkPolicyNames(policyContent: string): Set | null { - try { - // Lazy require: yaml is already a dependency via the policy helpers. - const YAML = require("yaml"); - const parsed = YAML.parse(policyContent); - const networkPolicies = parsed?.network_policies; - if ( - !networkPolicies || - typeof networkPolicies !== "object" || - Array.isArray(networkPolicies) - ) { - return new Set(); - } - return new Set(Object.keys(networkPolicies)); - } catch { - return null; - } -} - -function prepareInitialSandboxCreatePolicy( - basePolicyPath: string, - activeMessagingChannels: string[], - options: { directGpu?: boolean } = {}, -): InitialSandboxPolicy { - const directGpuPolicy = options.directGpu ? prepareDirectGpuSandboxPolicy(basePolicyPath) : null; - const effectiveBasePolicyPath = directGpuPolicy?.policyPath || basePolicyPath; - const cleanupFns = directGpuPolicy?.cleanup ? [directGpuPolicy.cleanup] : []; - const requestedCreateTimePresets = [ - ...new Set( - activeMessagingChannels.flatMap( - (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], - ), - ), - ]; - const combinedCleanup = - cleanupFns.length > 0 ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined; - - if (requestedCreateTimePresets.length === 0) { - return { - policyPath: effectiveBasePolicyPath, - appliedPresets: [], - cleanup: combinedCleanup, - }; - } - - const basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8"); - const basePolicyNames = getNetworkPolicyNames(basePolicy); - if (basePolicyNames === null) { - return { - policyPath: effectiveBasePolicyPath, - appliedPresets: [], - cleanup: combinedCleanup, - }; - } - const existingCreateTimePresets = requestedCreateTimePresets.filter((preset) => - basePolicyNames.has(preset), - ); - const createTimePresets = requestedCreateTimePresets.filter( - (preset) => !basePolicyNames.has(preset), - ); - if (createTimePresets.length === 0) { - return { - policyPath: effectiveBasePolicyPath, - appliedPresets: existingCreateTimePresets, - cleanup: combinedCleanup, - }; - } - - const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets); - if (mergedPolicy.missingPresets.length > 0) { - throw new Error( - `Cannot prepare sandbox create policy; missing policy preset(s): ${mergedPolicy.missingPresets.join(", ")}`, - ); - } - - const policyPath = secureTempFile("nemoclaw-initial-policy", ".yaml"); - fs.writeFileSync(policyPath, mergedPolicy.policy, { encoding: "utf-8", mode: 0o600 }); - cleanupFns.push(() => { - try { - cleanupTempDir(policyPath, "nemoclaw-initial-policy"); - return true; - } catch { - return false; - } - }); - - return { - policyPath, - appliedPresets: [...existingCreateTimePresets, ...mergedPolicy.appliedPresets], - cleanup: () => cleanupFns.map((cleanup) => cleanup()).every(Boolean), - }; -} - function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]) { const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); // upsertMessagingProviders process.exits on failure, so reaching this diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts new file mode 100644 index 0000000000..5ea0c476b4 --- /dev/null +++ b/src/lib/onboard/initial-policy.test.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../policy", () => ({ + mergePresetNamesIntoPolicy: (policy: string, presetNames: string[]) => ({ + policy: `${policy.trimEnd()}\n slack: {}\n`, + appliedPresets: presetNames, + missingPresets: [], + }), +})); + +import { getNetworkPolicyNames, prepareInitialSandboxCreatePolicy } from "./initial-policy"; + +const tmpRoots: string[] = []; + +function tmpPolicy(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-initial-policy-test-")); + tmpRoots.push(dir); + const file = path.join(dir, "base.yaml"); + fs.writeFileSync(file, content, "utf-8"); + return file; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("initial sandbox policy helpers", () => { + it("returns network policy names from a policy document", () => { + expect(getNetworkPolicyNames("version: 1\nnetwork_policies:\n slack: {}\n npm: {}\n")).toEqual( + new Set(["slack", "npm"]), + ); + }); + + it("returns null when policy YAML cannot be parsed", () => { + expect(getNetworkPolicyNames("network_policies: [unterminated")).toBeNull(); + }); + + it("keeps the base policy when no channel needs a create-time preset", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + + expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["telegram"])).toEqual({ + policyPath: basePolicyPath, + appliedPresets: [], + }); + }); + + it("records an existing create-time preset without writing a temp policy", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n slack: {}\n"); + + expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"])).toEqual({ + policyPath: basePolicyPath, + appliedPresets: ["slack"], + }); + }); + + it("merges missing create-time presets into a temporary policy", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"]); + + expect(prepared.policyPath).not.toBe(basePolicyPath); + expect(prepared.appliedPresets).toEqual(["slack"]); + expect(fs.readFileSync(prepared.policyPath, "utf-8")).toContain("slack"); + expect(prepared.cleanup?.()).toBe(true); + expect(fs.existsSync(prepared.policyPath)).toBe(false); + }); +}); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts new file mode 100644 index 0000000000..7fb9c68812 --- /dev/null +++ b/src/lib/onboard/initial-policy.ts @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import YAML from "yaml"; + +import * as policies from "../policy"; +import { cleanupTempDir, secureTempFile } from "./temp-files"; + +export type InitialSandboxPolicy = { + policyPath: string; + appliedPresets: string[]; + cleanup?: () => boolean; +}; + +const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record = { + slack: ["slack"], +}; + +const PROC_COMM_READ_WRITE_PATH = "/proc/self/task/*/comm"; + +export function buildDirectGpuPolicyYaml(basePolicy: string): string { + const parsed = YAML.parse(basePolicy); + if (!parsed || typeof parsed !== "object") { + throw new Error("Cannot prepare direct GPU sandbox policy; base policy is not a YAML mapping."); + } + parsed.filesystem_policy = parsed.filesystem_policy || {}; + const fsPolicy = parsed.filesystem_policy; + fsPolicy.read_only = Array.isArray(fsPolicy.read_only) + ? fsPolicy.read_only.map((entry: unknown) => String(entry)) + : []; + if (!fsPolicy.read_only.includes("/proc")) { + fsPolicy.read_only.push("/proc"); + } + const readWrite = Array.isArray(fsPolicy.read_write) + ? fsPolicy.read_write.map((entry: unknown) => String(entry)) + : []; + fsPolicy.read_write = readWrite.filter((entry: string) => entry !== "/proc"); + if (!fsPolicy.read_write.includes(PROC_COMM_READ_WRITE_PATH)) { + fsPolicy.read_write.push(PROC_COMM_READ_WRITE_PATH); + } + return YAML.stringify(parsed); +} + +const PROC_COMM_WRITE_PROBE = ` +set -eu +tid="$(ls /proc/self/task | head -n 1)" +old="$(cat "/proc/self/task/\${tid}/comm" 2>/dev/null || true)" +printf nemoclaw-gpu >"/proc/self/task/\${tid}/comm" +if [ -n "$old" ]; then printf "%s" "$old" >"/proc/self/task/\${tid}/comm" || true; fi +`; + +const CUDA_INIT_PROBE = ` +python3 - <<'PY' +import ctypes +lib = ctypes.CDLL("libcuda.so.1") +rc = lib.cuInit(0) +print(f"cuInit(0)={rc}") +raise SystemExit(0 if rc == 0 else 1) +PY +`; + +export function buildDirectSandboxGpuProofCommands( + sandboxName: string, +): { label: string; args: string[] }[] { + return [ + { + label: "nvidia-smi", + args: ["sandbox", "exec", "-n", sandboxName, "--", "nvidia-smi"], + }, + { + label: "/proc/self/task//comm write", + args: ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", PROC_COMM_WRITE_PROBE], + }, + { + label: "cuInit(0) via libcuda.so.1", + args: ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", CUDA_INIT_PROBE], + }, + ]; +} + +function prepareDirectGpuSandboxPolicy(basePolicyPath: string): InitialSandboxPolicy { + const basePolicy = fs.readFileSync(basePolicyPath, "utf-8"); + const policyPath = secureTempFile("nemoclaw-gpu-policy", ".yaml"); + fs.writeFileSync(policyPath, buildDirectGpuPolicyYaml(basePolicy), { + encoding: "utf-8", + mode: 0o600, + }); + return { + policyPath, + appliedPresets: [], + cleanup: () => { + try { + cleanupTempDir(policyPath, "nemoclaw-gpu-policy"); + return true; + } catch { + return false; + } + }, + }; +} + +export function getNetworkPolicyNames(policyContent: string): Set | null { + try { + const parsed = YAML.parse(policyContent); + const networkPolicies = parsed?.network_policies; + if ( + !networkPolicies || + typeof networkPolicies !== "object" || + Array.isArray(networkPolicies) + ) { + return new Set(); + } + return new Set(Object.keys(networkPolicies)); + } catch { + return null; + } +} + +export function prepareInitialSandboxCreatePolicy( + basePolicyPath: string, + activeMessagingChannels: string[], + options: { directGpu?: boolean } = {}, +): InitialSandboxPolicy { + const directGpuPolicy = options.directGpu ? prepareDirectGpuSandboxPolicy(basePolicyPath) : null; + const effectiveBasePolicyPath = directGpuPolicy?.policyPath || basePolicyPath; + const cleanupFns = directGpuPolicy?.cleanup ? [directGpuPolicy.cleanup] : []; + const requestedCreateTimePresets = [ + ...new Set( + activeMessagingChannels.flatMap( + (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], + ), + ), + ]; + const combinedCleanup = + cleanupFns.length > 0 ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined; + + if (requestedCreateTimePresets.length === 0) { + return { + policyPath: effectiveBasePolicyPath, + appliedPresets: [], + cleanup: combinedCleanup, + }; + } + + const basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8"); + const basePolicyNames = getNetworkPolicyNames(basePolicy); + if (basePolicyNames === null) { + return { + policyPath: effectiveBasePolicyPath, + appliedPresets: [], + cleanup: combinedCleanup, + }; + } + const existingCreateTimePresets = requestedCreateTimePresets.filter((preset) => + basePolicyNames.has(preset), + ); + const createTimePresets = requestedCreateTimePresets.filter( + (preset) => !basePolicyNames.has(preset), + ); + if (createTimePresets.length === 0) { + return { + policyPath: effectiveBasePolicyPath, + appliedPresets: existingCreateTimePresets, + cleanup: combinedCleanup, + }; + } + + const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets); + if (mergedPolicy.missingPresets.length > 0) { + throw new Error( + `Cannot prepare sandbox create policy; missing policy preset(s): ${mergedPolicy.missingPresets.join(", ")}`, + ); + } + + const policyPath = secureTempFile("nemoclaw-initial-policy", ".yaml"); + fs.writeFileSync(policyPath, mergedPolicy.policy, { encoding: "utf-8", mode: 0o600 }); + cleanupFns.push(() => { + try { + cleanupTempDir(policyPath, "nemoclaw-initial-policy"); + return true; + } catch { + return false; + } + }); + + return { + policyPath, + appliedPresets: [...existingCreateTimePresets, ...mergedPolicy.appliedPresets], + cleanup: () => cleanupFns.map((cleanup) => cleanup()).every(Boolean), + }; +} diff --git a/src/lib/onboard/temp-files.test.ts b/src/lib/onboard/temp-files.test.ts new file mode 100644 index 0000000000..d67ff63c9e --- /dev/null +++ b/src/lib/onboard/temp-files.test.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { cleanupTempDir, secureTempFile } from "./temp-files"; + +const createdParents: string[] = []; + +afterEach(() => { + for (const parent of createdParents.splice(0)) { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +describe("onboard temp file helpers", () => { + it("creates a file path under a unique prefixed temp directory", () => { + const filePath = secureTempFile("nemoclaw-test", ".txt"); + const parent = path.dirname(filePath); + createdParents.push(parent); + + expect(path.basename(parent).startsWith("nemoclaw-test-")).toBe(true); + expect(path.basename(filePath)).toBe("nemoclaw-test.txt"); + }); + + it("rejects temp prefixes with path separators", () => { + expect(() => secureTempFile("../nemoclaw-test", ".txt")).toThrow("Invalid temp file prefix"); + expect(() => secureTempFile("nested/nemoclaw-test", ".txt")).toThrow( + "Invalid temp file prefix", + ); + expect(() => secureTempFile("nested\\nemoclaw-test", ".txt")).toThrow( + "Invalid temp file prefix", + ); + }); + + it("removes only the matching mkdtemp-created parent directory", () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + fs.writeFileSync(filePath, "payload"); + + cleanupTempDir(filePath, "nemoclaw-cleanup"); + + expect(fs.existsSync(parent)).toBe(false); + }); + + it("does not remove unrelated temp directories", () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "other-prefix-")); + createdParents.push(parent); + const filePath = path.join(parent, "nemoclaw-cleanup.txt"); + fs.writeFileSync(filePath, "payload"); + + cleanupTempDir(filePath, "nemoclaw-cleanup"); + + expect(fs.existsSync(parent)).toBe(true); + }); + + it("does not remove matching-prefix directories outside os.tmpdir()", () => { + const outsideParent = fs.mkdtempSync(path.join(process.cwd(), "nemoclaw-cleanup-")); + createdParents.push(outsideParent); + const filePath = path.join(outsideParent, "nemoclaw-cleanup.txt"); + fs.writeFileSync(filePath, "payload"); + + cleanupTempDir(filePath, "nemoclaw-cleanup"); + + expect(fs.existsSync(outsideParent)).toBe(true); + }); +}); diff --git a/src/lib/onboard/temp-files.ts b/src/lib/onboard/temp-files.ts new file mode 100644 index 0000000000..f709f5a703 --- /dev/null +++ b/src/lib/onboard/temp-files.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Create a temp file inside a directory with a cryptographically random name. + * Uses fs.mkdtempSync (OS-level mkdtemp) to avoid predictable filenames that + * could be exploited via symlink attacks on shared /tmp. + * Ref: https://github.com/NVIDIA/NemoClaw/issues/1093 + */ +function validateTempPrefix(prefix: string): string { + if ( + prefix.length === 0 || + prefix !== path.basename(prefix) || + prefix.includes(path.posix.sep) || + prefix.includes(path.win32.sep) + ) { + throw new Error(`Invalid temp file prefix: ${prefix}`); + } + return prefix; +} + +export function secureTempFile(prefix: string, ext = ""): string { + const safePrefix = validateTempPrefix(prefix); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${safePrefix}-`)); + return path.join(dir, `${safePrefix}${ext}`); +} + +/** + * Safely remove a mkdtemp-created directory. Guards against accidentally + * deleting the system temp root if a caller passes os.tmpdir() itself. + */ +export function cleanupTempDir(filePath: string, expectedPrefix: string): void { + const safePrefix = validateTempPrefix(expectedPrefix); + const tempRoot = path.resolve(os.tmpdir()); + const parentDir = path.resolve(path.dirname(filePath)); + const relativeParent = path.relative(tempRoot, parentDir); + const isInsideTempRoot = + relativeParent !== "" && !relativeParent.startsWith("..") && !path.isAbsolute(relativeParent); + if (isInsideTempRoot && path.basename(parentDir).startsWith(`${safePrefix}-`)) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } +}