Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
7 changes: 7 additions & 0 deletions bin/lib/config-io.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Thin re-export shim — the implementation lives in src/lib/config-io.ts,
// compiled to dist/lib/config-io.js.

module.exports = require("../../dist/lib/config-io");
18 changes: 3 additions & 15 deletions bin/lib/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const path = require("path");
const os = require("os");
const readline = require("readline");
const { execFileSync } = require("child_process");
const { readConfigFile, writeConfigFile } = require("./config-io");

const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]);

Expand Down Expand Up @@ -56,15 +57,7 @@ function getCredsFile() {
}

function loadCredentials() {
try {
const file = getCredsFile();
if (fs.existsSync(file)) {
return JSON.parse(fs.readFileSync(file, "utf-8"));
}
} catch {
/* ignored */
}
return {};
return readConfigFile(getCredsFile(), {});
}

function normalizeCredentialValue(value) {
Expand All @@ -73,14 +66,9 @@ function normalizeCredentialValue(value) {
}

function saveCredential(key, value) {
const dir = getCredsDir();
const file = getCredsFile();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700);
const creds = loadCredentials();
creds[key] = normalizeCredentialValue(value);
fs.writeFileSync(file, JSON.stringify(creds, null, 2), { mode: 0o600 });
fs.chmodSync(file, 0o600);
writeConfigFile(getCredsFile(), creds);
}

function getCredential(key) {
Expand Down
19 changes: 19 additions & 0 deletions bin/lib/policies.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ function getPresetEndpoints(content) {
return hosts;
}

/**
* Validate that a preset contains a binaries section.
* Presets without binaries cause 403 errors because the egress proxy
* has no approved binary list and denies all traffic (ref: #676).
*/
function validatePreset(presetContent, presetName) {
if (!presetContent.includes("binaries:")) {
console.warn(
` Warning: preset '${presetName}' has no binaries section — ` +
`this will cause 403 errors in the sandbox (ref: #676)`,
);
return false;
}
return true;
}

/**
* Extract just the network_policies entries (indented content under
* the `network_policies:` key) from a preset file, stripping the
Expand Down Expand Up @@ -233,6 +249,8 @@ function applyPreset(sandboxName, presetName) {
return false;
}

validatePreset(presetContent, presetName);

const presetEntries = extractPresetEntries(presetContent);
if (!presetEntries) {
console.error(` Preset ${presetName} has no network_policies section.`);
Expand Down Expand Up @@ -294,6 +312,7 @@ module.exports = {
loadPreset,
getPresetEndpoints,
extractPresetEntries,
validatePreset,
parseCurrentPolicy,
buildPolicySetCommand,
buildPolicyGetCommand,
Expand Down
27 changes: 3 additions & 24 deletions bin/lib/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

const fs = require("fs");
const path = require("path");
const { readConfigFile, writeConfigFile } = require("./config-io");

const REGISTRY_FILE = path.join(process.env.HOME || "/tmp", ".nemoclaw", "sandboxes.json");
const LOCK_DIR = REGISTRY_FILE + ".lock";
Expand Down Expand Up @@ -121,33 +122,11 @@ function withLock(fn) {
}

function load() {
try {
if (fs.existsSync(REGISTRY_FILE)) {
return JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf-8"));
}
} catch {
/* ignored */
}
return { sandboxes: {}, defaultSandbox: null };
return readConfigFile(REGISTRY_FILE, { sandboxes: {}, defaultSandbox: null });
}

/** Atomic write: tmp file + rename on the same filesystem. */
function save(data) {
const dir = path.dirname(REGISTRY_FILE);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tmp = REGISTRY_FILE + ".tmp." + process.pid;
try {
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
fs.renameSync(tmp, REGISTRY_FILE);
} catch (err) {
// Clean up partial temp file on failure
try {
fs.unlinkSync(tmp);
} catch {
/* best effort */
}
throw err;
}
writeConfigFile(REGISTRY_FILE, data);
}

function getSandbox(name) {
Expand Down
107 changes: 107 additions & 0 deletions src/lib/config-io.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

// Import from compiled dist/ for coverage attribution.
import {
ensureConfigDir,
readConfigFile,
writeConfigFile,
ConfigPermissionError,
} from "../../dist/lib/config-io";

describe("config-io", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-config-io-"));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe("ensureConfigDir", () => {
it("creates a directory with mode 0o700", () => {
const dir = path.join(tmpDir, "nested", "config");
ensureConfigDir(dir);
expect(fs.existsSync(dir)).toBe(true);
const stat = fs.statSync(dir);
expect(stat.mode & 0o777).toBe(0o700);
});

it("succeeds if directory already exists", () => {
const dir = path.join(tmpDir, "existing");
fs.mkdirSync(dir, { mode: 0o700 });
expect(() => ensureConfigDir(dir)).not.toThrow();
});
});

describe("writeConfigFile + readConfigFile", () => {
it("round-trips JSON data with atomic write", () => {
const file = path.join(tmpDir, "test.json");
const data = { key: "value", nested: { a: 1 } };
writeConfigFile(file, data);

expect(fs.existsSync(file)).toBe(true);
const stat = fs.statSync(file);
expect(stat.mode & 0o777).toBe(0o600);

const loaded = readConfigFile(file, {});
expect(loaded).toEqual(data);
});

it("creates parent directories", () => {
const file = path.join(tmpDir, "deep", "nested", "config.json");
writeConfigFile(file, { ok: true });
expect(readConfigFile(file, null)).toEqual({ ok: true });
});

it("returns default for missing files", () => {
const file = path.join(tmpDir, "nonexistent.json");
expect(readConfigFile(file, { fallback: true })).toEqual({ fallback: true });
});

it("returns default for corrupt JSON", () => {
const file = path.join(tmpDir, "corrupt.json");
fs.writeFileSync(file, "not-json");
expect(readConfigFile(file, "default")).toBe("default");
});

it("cleans up temp file on write failure", () => {
// Write to a read-only directory to trigger failure
const readonlyDir = path.join(tmpDir, "readonly");
fs.mkdirSync(readonlyDir, { mode: 0o700 });
const file = path.join(readonlyDir, "test.json");
// Write once successfully, then make dir read-only
writeConfigFile(file, { first: true });
fs.chmodSync(readonlyDir, 0o500);

try {
expect(() => writeConfigFile(file, { second: true })).toThrow();
} finally {
fs.chmodSync(readonlyDir, 0o700);
}

// No temp files left behind
const files = fs.readdirSync(readonlyDir);
expect(files.filter((f) => f.includes(".tmp."))).toHaveLength(0);
});
});

describe("ConfigPermissionError", () => {
it("includes remediation message and config path", () => {
const err = new ConfigPermissionError("test error", "/some/path");
expect(err.name).toBe("ConfigPermissionError");
expect(err.code).toBe("EACCES");
expect(err.configPath).toBe("/some/path");
expect(err.message).toContain("test error");
expect(err.remediation).toContain("sudo chown");
expect(err.remediation).toContain(".nemoclaw");
});
});
});
123 changes: 123 additions & 0 deletions src/lib/config-io.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Safe config file I/O with EACCES error handling (#692, #606, #719).

import fs from "node:fs";
import path from "node:path";
import os from "node:os";

/**
* Custom error for config permission problems. Carries the path and
* a user-facing remediation message so callers can display it cleanly.
*/
export class ConfigPermissionError extends Error {
code = "EACCES";
configPath: string;
remediation: string;

constructor(message: string, configPath: string, cause?: Error) {
const remediation = buildRemediation();
super(`${message}\n\n${remediation}`);
this.name = "ConfigPermissionError";
this.configPath = configPath;
this.remediation = remediation;
if (cause) this.cause = cause;
}
}

function buildRemediation(): string {
const home = process.env.HOME || os.homedir();
const nemoclawDir = path.join(home, ".nemoclaw");
return [
" To fix, run one of:",
"",
` sudo chown -R $(whoami) ${nemoclawDir}`,
` # or, if the directory was created by another user:`,
` sudo rm -rf ${nemoclawDir} && nemoclaw onboard`,
"",
" This usually happens when NemoClaw was first run with sudo",
" or the config directory was created by a different user.",
].join("\n");
Comment on lines +10 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remediation still assumes sudo exists.

Issue #692 explicitly includes environments where sudo is unavailable, but both suggested fixes here still require it. In those installs the new ConfigPermissionError is still not actionable. Please add a non-sudo fallback, e.g. recreating config under a user-writable HOME or config directory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/config-io.ts` around lines 29 - 41, The current buildRemediation()
message assumes sudo is available; update it to include non-sudo fallback
actions so environments without sudo get actionable guidance. In the
buildRemediation() function (reference: nemoclawDir and HOME usage) add
alternative suggestions such as recreating the config under the current user's
home (e.g., remove or move the directory if writable), instructing to remove the
directory without sudo when the user owns it (e.g., rm -rf $HOME/.nemoclaw), and
advising to relocate or initialize config in a user-writable path (for example
creating a new config under $HOME or specifying an alternative CONFIG_HOME), so
the error message covers both sudo and non-sudo environments. Ensure the text
clearly distinguishes when sudo is required vs when the non-sudo command
applies.

}

/**
* Ensure a directory exists with mode 0o700. On EACCES, throws
* ConfigPermissionError with remediation hints.
*/
export function ensureConfigDir(dir: string): void {
try {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "EACCES") {
throw new ConfigPermissionError(`Cannot create config directory: ${dir}`, dir, err as Error);
}
throw err;
}

try {
fs.accessSync(dir, fs.constants.W_OK);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "EACCES") {
throw new ConfigPermissionError(
`Config directory exists but is not writable: ${dir}`,
dir,
err as Error,
);
}
throw err;
}
}

/**
* Write a JSON config file atomically with mode 0o600.
* Uses write-to-temp + rename to avoid partial writes on crash.
*/
export function writeConfigFile(filePath: string, data: unknown): void {
const dir = path.dirname(filePath);
ensureConfigDir(dir);

const content = JSON.stringify(data, null, 2);
const tmpFile = filePath + ".tmp." + process.pid;

try {
fs.writeFileSync(tmpFile, content, { mode: 0o600 });
fs.renameSync(tmpFile, filePath);
} catch (err: unknown) {
try {
fs.unlinkSync(tmpFile);
} catch {
/* best effort cleanup */
}
if ((err as NodeJS.ErrnoException).code === "EACCES") {
throw new ConfigPermissionError(
`Cannot write config file: ${filePath}`,
filePath,
err as Error,
);
}
throw err;
}
}

/**
* Read and parse a JSON config file. Returns defaultValue on missing
* or corrupt files. On EACCES, throws ConfigPermissionError.
*/
export function readConfigFile<T>(filePath: string, defaultValue: T): T {
try {
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "EACCES") {
throw new ConfigPermissionError(
`Cannot read config file: ${filePath}`,
filePath,
err as Error,
);
}
// Corrupt JSON or other non-permission error — return default
}
return defaultValue;
}
4 changes: 3 additions & 1 deletion test/registry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ describe("atomic writes", () => {
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
};
try {
expect(() => registry.save({ sandboxes: {}, defaultSandbox: null })).toThrow("EACCES");
expect(() => registry.save({ sandboxes: {}, defaultSandbox: null })).toThrow(
"Cannot write config file",
);
} finally {
fs.renameSync = original;
}
Expand Down
Loading