Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
68 changes: 60 additions & 8 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,10 +1459,34 @@ function sandboxLogs(sandboxName, follow) {

async function sandboxPolicyAdd(sandboxName, args = []) {
const dryRun = args.includes("--dry-run");
const skipConfirm =
args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1";
const allPresets = policies.listPresets();
const applied = policies.getAppliedPresets(sandboxName);

const answer = await policies.selectFromList(allPresets, { applied });
const presetArg = args.find((arg) => !arg.startsWith("-"));
let answer = null;
if (presetArg) {
const normalized = presetArg.trim().toLowerCase();
const preset = allPresets.find((item) => item.name === normalized);
if (!preset) {
console.error(` Unknown preset '${presetArg}'.`);
console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`);
process.exit(1);
}
if (applied.includes(preset.name)) {
console.error(` Preset '${preset.name}' is already applied.`);
process.exit(1);
}
answer = preset.name;
} else {
if (process.env.NEMOCLAW_NON_INTERACTIVE === "1") {
console.error(" Non-interactive mode requires a preset name.");
console.error(" Usage: nemoclaw <sandbox> policy-add <preset> [--yes] [--dry-run]");
process.exit(1);
}
answer = await policies.selectFromList(allPresets, { applied });
}
if (!answer) return;

const presetContent = policies.loadPreset(answer);
Expand All @@ -1478,8 +1502,10 @@ async function sandboxPolicyAdd(sandboxName, args = []) {
return;
}

const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `);
if (confirm.toLowerCase() === "n") return;
if (!skipConfirm) {
const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `);
if (confirm.toLowerCase() === "n") return;
}

policies.applyPreset(sandboxName, answer);
}
Expand Down Expand Up @@ -1681,10 +1707,34 @@ async function sandboxSkillInstall(sandboxName, args = []) {

async function sandboxPolicyRemove(sandboxName, args = []) {
const dryRun = args.includes("--dry-run");
const skipConfirm =
args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1";
const allPresets = policies.listPresets();
const applied = policies.getAppliedPresets(sandboxName);

const answer = await policies.selectForRemoval(allPresets, { applied });
const presetArg = args.find((arg) => !arg.startsWith("-"));
let answer = null;
if (presetArg) {
const normalized = presetArg.trim().toLowerCase();
const preset = allPresets.find((item) => item.name === normalized);
if (!preset) {
console.error(` Unknown preset '${presetArg}'.`);
console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`);
process.exit(1);
}
if (!applied.includes(preset.name)) {
console.error(` Preset '${preset.name}' is not applied.`);
process.exit(1);
}
answer = preset.name;
} else {
if (process.env.NEMOCLAW_NON_INTERACTIVE === "1") {
console.error(" Non-interactive mode requires a preset name.");
console.error(" Usage: nemoclaw <sandbox> policy-remove <preset> [--yes] [--dry-run]");
process.exit(1);
}
answer = await policies.selectForRemoval(allPresets, { applied });
}
if (!answer) return;

const presetContent = policies.loadPreset(answer);
Expand All @@ -1700,8 +1750,10 @@ async function sandboxPolicyRemove(sandboxName, args = []) {
return;
}

const confirm = await askPrompt(` Remove '${answer}' from sandbox '${sandboxName}'? [Y/n]: `);
if (confirm.toLowerCase() === "n") return;
if (!skipConfirm) {
const confirm = await askPrompt(` Remove '${answer}' from sandbox '${sandboxName}'? [Y/n]: `);
if (confirm.toLowerCase() === "n") return;
}

if (!policies.removePreset(sandboxName, answer)) {
process.exit(1);
Expand Down Expand Up @@ -2356,8 +2408,8 @@ function help() {
nemoclaw <name> skill install <path> Deploy a skill directory to the sandbox

${G}Policy Presets:${R}
nemoclaw <name> policy-add Add a network or filesystem policy preset ${D}(--dry-run to preview)${R}
nemoclaw <name> policy-remove Remove an applied policy preset ${D}(--dry-run to preview)${R}
nemoclaw <name> policy-add [preset] Add a network or filesystem policy preset ${D}(--yes, --dry-run)${R}
nemoclaw <name> policy-remove [preset] Remove an applied policy preset ${D}(--yes, --dry-run)${R}
nemoclaw <name> policy-list List presets ${D}(● = applied)${R}

${G}Compatibility Commands:${R}
Expand Down
72 changes: 70 additions & 2 deletions test/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const SELECT_FROM_LIST_ITEMS = [
{ name: "pypi", description: "Python Package Index (PyPI) access" },
];

function runPolicyAdd(confirmAnswer, extraArgs = []) {
function runPolicyAdd(confirmAnswer, extraArgs = [], envOverrides = {}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-"));
const scriptPath = path.join(tmpDir, "policy-add-check.js");
const script = String.raw`
Expand Down Expand Up @@ -60,6 +60,7 @@ setImmediate(() => {
env: {
...process.env,
HOME: tmpDir,
...envOverrides,
},
});
}
Expand Down Expand Up @@ -889,10 +890,43 @@ selectForRemoval(items, options)
expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/);
expect(result.stdout).toMatch(/--dry-run: no changes applied\./);
});

it("accepts a preset name with --yes for headless use", () => {
const result = runPolicyAdd("n", ["pypi", "--yes"]);

expect(result.status).toBe(0);
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
expect(calls).toContainEqual({
type: "apply",
sandboxName: "test-sandbox",
presetName: "pypi",
});
});

it("honors non-interactive mode when a preset name is provided", () => {
const result = runPolicyAdd("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" });

expect(result.status).toBe(0);
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
expect(calls).toContainEqual({
type: "apply",
sandboxName: "test-sandbox",
presetName: "pypi",
});
});

it("fails fast in non-interactive mode without a preset name", () => {
const result = runPolicyAdd("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" });

expect(result.status).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/);
});
});

describe("policy-remove confirmation", () => {
function runPolicyRemove(confirmAnswer, extraArgs = []) {
function runPolicyRemove(confirmAnswer, extraArgs = [], envOverrides = {}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-remove-"));
const scriptPath = path.join(tmpDir, "policy-remove-check.js");
const script = String.raw`
Expand Down Expand Up @@ -933,6 +967,7 @@ setImmediate(() => {
env: {
...process.env,
HOME: tmpDir,
...envOverrides,
},
});
}
Expand Down Expand Up @@ -975,5 +1010,38 @@ setImmediate(() => {
expect(result.stdout).toMatch(/Endpoints that would be removed: pypi\.org/);
expect(result.stdout).toMatch(/--dry-run: no changes applied\./);
});

it("accepts a preset name with --yes for scripted removal", () => {
const result = runPolicyRemove("n", ["pypi", "--yes"]);

expect(result.status).toBe(0);
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
expect(calls).toContainEqual({
type: "remove",
sandboxName: "test-sandbox",
presetName: "pypi",
});
});

it("honors non-interactive mode when removing an explicit preset", () => {
const result = runPolicyRemove("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" });

expect(result.status).toBe(0);
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
expect(calls).toContainEqual({
type: "remove",
sandboxName: "test-sandbox",
presetName: "pypi",
});
});

it("fails fast in non-interactive mode without a preset name", () => {
const result = runPolicyRemove("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" });

expect(result.status).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/);
});
});
});
Loading