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
22 changes: 22 additions & 0 deletions src/lib/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ function getPresetEndpoints(content: string): string[] {
return hosts;
}

/**
* Messaging channel presets only open network egress to the provider's API;
* the bot token, channel configuration, and in-sandbox bridge are wired up at
* `nemoclaw onboard` time, so applying these presets after onboarding without
* having enabled the channel opens the firewall but leaves the sandbox
* without a running bridge. See #1691.
*/
const MESSAGING_PRESET_NAMES = new Set(["telegram", "discord", "slack"]);

function getMessagingPresetWarning(presetName: string): string | null {
if (!MESSAGING_PRESET_NAMES.has(presetName)) return null;
const label =
presetName === "telegram" ? "Telegram" : presetName === "discord" ? "Discord" : "Slack";
return [
`Note: the '${presetName}' preset only opens network egress to the ${label} API.`,
`To actually enable ${label} messaging, re-run 'nemoclaw onboard' and select ${label}`,
"in the messaging channels step. The bot token and channel bridge are wired",
"up at onboard time and are not added by applying this preset alone.",
].join("\n ");
}

/**
* Extract just the network_policies entries (indented content under
* the `network_policies:` key) from a preset file, stripping the
Expand Down Expand Up @@ -919,6 +940,7 @@ export {
listPresets,
loadPreset,
getPresetEndpoints,
getMessagingPresetWarning,
extractPresetEntries,
parseCurrentPolicy,
buildPolicySetCommand,
Expand Down
7 changes: 7 additions & 0 deletions src/lib/policy-channel-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ export async function addSandboxPolicy(sandboxName: string, args: string[] = [])
console.log(` Endpoints that would be opened: ${endpoints.join(", ")}`);
}

const messagingWarning = policies.getMessagingPresetWarning(answer);
if (messagingWarning) {
console.log("");
console.log(` ${messagingWarning}`);
console.log("");
}

if (dryRun) {
console.log(" --dry-run: no changes applied.");
return;
Expand Down
76 changes: 63 additions & 13 deletions test/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function runPolicyAdd(
confirmAnswer: string,
extraArgs: string[] = [],
envOverrides: Record<string, string | undefined> = {},
presetName: string = "pypi",
) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-"));
const scriptPath = path.join(tmpDir, "policy-add-check.js");
Expand All @@ -57,9 +58,9 @@ const registry = require(${REGISTRY_PATH});
const policies = require(${POLICIES_PATH});
const credentials = require(${CREDENTIALS_PATH});
const calls = [];
policies.selectFromList = async () => "pypi";
policies.loadPreset = () => "network_policies:\n pypi:\n host: pypi.org\n";
policies.getPresetEndpoints = () => ["pypi.org"];
policies.selectFromList = async () => ${JSON.stringify(presetName)};
policies.loadPreset = () => "network_policies:\n example:\n host: example.com\n";
policies.getPresetEndpoints = () => ["example.com"];
credentials.prompt = async (message) => {
calls.push({ type: "prompt", message });
return ${JSON.stringify(confirmAnswer)};
Expand All @@ -82,15 +83,19 @@ Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => {

fs.writeFileSync(scriptPath, script);

return spawnSync(process.execPath, [scriptPath], {
cwd: REPO_ROOT,
encoding: "utf-8",
env: {
...process.env,
HOME: tmpDir,
...envOverrides,
},
});
try {
return spawnSync(process.execPath, [scriptPath], {
cwd: REPO_ROOT,
encoding: "utf-8",
env: {
...process.env,
HOME: tmpDir,
...envOverrides,
},
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

function runSelectFromList(input: string, { applied = [] }: AppliedOptions = {}) {
Expand Down Expand Up @@ -250,6 +255,33 @@ describe("policies", () => {
});
});

describe("getMessagingPresetWarning", () => {
it("returns a warning for the telegram preset that mentions re-running onboard", () => {
const warning = policies.getMessagingPresetWarning("telegram");
expect(warning).toBeTruthy();
expect(warning).toContain("telegram");
expect(warning).toContain("Telegram");
expect(warning).toContain("nemoclaw onboard");
});

it("returns a warning for discord and slack", () => {
expect(policies.getMessagingPresetWarning("discord")).toContain("Discord");
expect(policies.getMessagingPresetWarning("slack")).toContain("Slack");
});

it("returns null for non-messaging presets", () => {
expect(policies.getMessagingPresetWarning("npm")).toBeNull();
expect(policies.getMessagingPresetWarning("pypi")).toBeNull();
expect(policies.getMessagingPresetWarning("github")).toBeNull();
expect(policies.getMessagingPresetWarning("brew")).toBeNull();
});

it("returns null for unknown preset names", () => {
expect(policies.getMessagingPresetWarning("")).toBeNull();
expect(policies.getMessagingPresetWarning("nonexistent")).toBeNull();
});
});

describe("applyPreset disclosure logging", () => {
it("logs egress endpoints before applying", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
Expand Down Expand Up @@ -1035,7 +1067,7 @@ selectForRemoval(items, options)
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[];
expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy();
expect(calls.some((call: PolicyCall) => call.type === "apply")).toBeFalsy();
expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/);
expect(result.stdout).toMatch(/Endpoints that would be opened: example\.com/);
expect(result.stdout).toMatch(/--dry-run: no changes applied\./);
});

Expand Down Expand Up @@ -1073,6 +1105,24 @@ selectForRemoval(items, options)
/Non-interactive mode requires a preset name/,
);
});

it("warns the user that the telegram preset alone does not enable Telegram messaging", () => {
const result = runPolicyAdd("y", [], {}, "telegram");

expect(result.status).toBe(0);
expect(result.stdout).toMatch(
/Note: the 'telegram' preset only opens network egress to the Telegram API\./,
);
expect(result.stdout).toMatch(/re-run 'nemoclaw onboard' and select Telegram/);
});

it("does not warn about messaging when a non-messaging preset is selected", () => {
const result = runPolicyAdd("y");

expect(result.status).toBe(0);
expect(result.stdout).not.toMatch(/only opens network egress to the/);
expect(result.stdout).not.toMatch(/re-run 'nemoclaw onboard' and select/);
});
});

describe("policy-remove confirmation", () => {
Expand Down
Loading