Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
73c58d2
refactor(cli): group remaining architecture modules
cv May 8, 2026
8f3ebc1
refactor(onboard): extract initial policy helpers
cv May 8, 2026
57b3d61
Merge remote-tracking branch 'origin/main' into refactor/cli-architec…
cv May 9, 2026
1783c07
Merge branch 'refactor/cli-architecture-layout' into refactor/onboard…
cv May 9, 2026
bd0ffe5
test(inference): expect Kimi status thinking flag
cv May 9, 2026
5f51c03
Merge branch 'refactor/cli-architecture-layout' into refactor/onboard…
cv May 9, 2026
374abc5
Merge remote-tracking branch 'origin/main' into refactor/cli-architec…
cv May 9, 2026
76500de
Merge branch 'refactor/cli-architecture-layout' into refactor/onboard…
cv May 9, 2026
955225f
Merge branch 'main' into refactor/cli-architecture-layout
cv May 9, 2026
18ee079
merge main into cli architecture layout
cv May 9, 2026
2cbb302
test(policy): update tier onboarding policy import
cv May 9, 2026
ed06048
Merge branch 'refactor/cli-architecture-layout' of https://github.com…
cv May 9, 2026
d775298
Merge branch 'refactor/cli-architecture-layout' into refactor/onboard…
cv May 9, 2026
13bcc3a
Merge branch 'main' into refactor/cli-architecture-layout
cv May 10, 2026
96008e0
merge(main): update architecture layout stack base
cv May 11, 2026
32cb2b7
merge(stack): update initial policy helpers branch
cv May 11, 2026
c86a8ed
merge(main): refresh architecture layout branch
cv May 11, 2026
31b255d
Merge branch 'main' into refactor/cli-architecture-layout
cv May 11, 2026
23efdf3
Merge branch 'main' into refactor/cli-architecture-layout
cv May 11, 2026
37d5704
merge(main): refresh architecture layout branch
cv May 11, 2026
5177213
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
cv May 11, 2026
de52330
Merge branch 'refactor/cli-architecture-layout' into refactor/onboard…
cv May 11, 2026
b78185f
Merge remote-tracking branch 'origin/main' into refactor/onboard-init…
cv May 11, 2026
2972356
fix(onboard): harden temp file cleanup
cv May 11, 2026
6ba281c
Merge branch 'main' into refactor/onboard-initial-policy
cv May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/lib/adapters/http/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Expand Down
218 changes: 9 additions & 209 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const crypto = require("node:crypto");
const fs = require("fs");
const os = require("os");
Expand Down Expand Up @@ -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" : "";
Expand Down Expand Up @@ -2102,79 +2089,6 @@ type SelectionDrift = {
unknown: boolean;
};

type InitialSandboxPolicy = {
policyPath: string;
appliedPresets: string[];
cleanup?: () => boolean;
};

const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record<string, string[]> = {
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/<tid>/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)) {
Expand All @@ -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<string> | 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
Expand Down
76 changes: 76 additions & 0 deletions src/lib/onboard/initial-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading