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
6 changes: 5 additions & 1 deletion agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,11 @@ network_policies:
- { path: /usr/bin/python3* }
- { path: /opt/hermes/.venv/bin/python }

# ── Messaging — pre-allowed for agent notifications ───────────
# ── Messaging policy templates ─────────────────────────────────
# These entries are agent-specific channel templates. During sandbox
# creation, NemoClaw filters out entries for messaging channels that were not
# selected, so a Discord-only Hermes sandbox does not retain Telegram, Slack,
# or WeChat egress.
telegram:
name: telegram
endpoints:
Expand Down
53 changes: 53 additions & 0 deletions src/lib/onboard/initial-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,59 @@ network_policies:
});
});

it("records active channel policies already provided by an agent base policy", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n discord: {}\n");

expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["discord"])).toEqual({
policyPath: basePolicyPath,
appliedPresets: ["discord"],
});
});

it("filters inactive Hermes messaging policies from the create-time policy", () => {
const basePolicyPath = tmpPolicy(
[
"version: 1",
"network_policies:",
" pypi: {}",
" telegram: {}",
" discord: {}",
" slack: {}",
" wechat_bridge: {}",
"",
].join("\n"),
);

const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["discord"], {
agentName: "hermes",
});

expect(prepared.policyPath).not.toBe(basePolicyPath);
expect(prepared.appliedPresets).toEqual(["discord"]);
expect(getNetworkPolicyNames(fs.readFileSync(prepared.policyPath, "utf-8"))).toEqual(
new Set(["pypi", "discord"]),
);
expect(prepared.cleanup?.()).toBe(true);
expect(fs.existsSync(prepared.policyPath)).toBe(false);
});

it("filters inactive Hermes messaging policies from the relative Hermes policy path", () => {
const hermesPolicyPath = path.relative(
process.cwd(),
path.join(import.meta.dirname, "..", "..", "..", "agents", "hermes", "policy-additions.yaml"),
);

const prepared = prepareInitialSandboxCreatePolicy(hermesPolicyPath, ["discord"]);
const policyNames = getNetworkPolicyNames(fs.readFileSync(prepared.policyPath, "utf-8"));

expect(policyNames?.has("discord")).toBe(true);
expect(policyNames?.has("telegram")).toBe(false);
expect(policyNames?.has("slack")).toBe(false);
expect(policyNames?.has("wechat_bridge")).toBe(false);
expect(prepared.cleanup?.()).toBe(true);
expect(fs.existsSync(prepared.policyPath)).toBe(false);
});

it("merges missing create-time presets into a temporary policy", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n");

Expand Down
108 changes: 93 additions & 15 deletions src/lib/onboard/initial-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";

import * as policies from "../policy";
Expand All @@ -17,6 +18,13 @@ const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record<string, string[]> = {
slack: ["slack"],
};

const HERMES_MESSAGING_POLICY_KEYS: Record<string, string[]> = {
discord: ["discord"],
slack: ["slack"],
telegram: ["telegram"],
wechat: ["wechat_bridge"],
};

const PROC_PATH = "/proc";
const PROC_COMM_READ_WRITE_PATHS = ["/proc/self/comm", "/proc/self/task/*/comm"];

Expand Down Expand Up @@ -161,18 +169,63 @@ export function getNetworkPolicyNames(policyContent: string): Set<string> | null
}
}

function isYamlObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function filterHermesInactiveMessagingPolicies(
policyContent: string,
activeMessagingChannels: string[],
): { content: string; changed: boolean } {
const parsed = YAML.parse(policyContent);
if (!isYamlObject(parsed) || !isYamlObject(parsed.network_policies)) {
return { content: policyContent, changed: false };
}

const active = new Set(activeMessagingChannels);
let changed = false;
for (const [channel, policyKeys] of Object.entries(HERMES_MESSAGING_POLICY_KEYS)) {
if (active.has(channel)) continue;
for (const key of policyKeys) {
if (Object.prototype.hasOwnProperty.call(parsed.network_policies, key)) {
delete parsed.network_policies[key];
changed = true;
}
}
}

return {
content: changed ? YAML.stringify(parsed) : policyContent,
changed,
};
}

function isHermesPolicyPath(policyPath: string): boolean {
const normalized = policyPath.split(path.sep).join("/");
return /(^|\/)agents\/hermes\/policy-additions\.yaml$/.test(normalized);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function prepareInitialSandboxCreatePolicy(
basePolicyPath: string,
activeMessagingChannels: string[],
options: { directGpu?: boolean; dockerGpuPatch?: boolean; additionalPresets?: string[] } = {},
options: {
directGpu?: boolean;
dockerGpuPatch?: boolean;
additionalPresets?: string[];
agentName?: string | null;
} = {},
): InitialSandboxPolicy {
const directGpuPolicy = options.directGpu
? prepareDirectGpuSandboxPolicy(basePolicyPath, {
procReadWrite: options.dockerGpuPatch === true,
})
: null;
const effectiveBasePolicyPath = directGpuPolicy?.policyPath || basePolicyPath;
let effectiveBasePolicyPath = directGpuPolicy?.policyPath || basePolicyPath;
const cleanupFns = directGpuPolicy?.cleanup ? [directGpuPolicy.cleanup] : [];
const buildCleanup = () =>
cleanupFns.length > 0
? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean)
: undefined;
const requestedCreateTimePresets = [
...new Set(
[
Expand All @@ -183,26 +236,47 @@ export function prepareInitialSandboxCreatePolicy(
],
),
];
const combinedCleanup =
cleanupFns.length > 0 ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined;
const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))];

if (requestedCreateTimePresets.length === 0) {
let basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8");
if (options.agentName === "hermes" || isHermesPolicyPath(basePolicyPath)) {
const filtered = filterHermesInactiveMessagingPolicies(basePolicy, activeMessagingChannels);
if (filtered.changed) {
const policyPath = secureTempFile("nemoclaw-agent-policy", ".yaml");
fs.writeFileSync(policyPath, filtered.content, { encoding: "utf-8", mode: 0o600 });
cleanupFns.push(() => {
try {
cleanupTempDir(policyPath, "nemoclaw-agent-policy");
return true;
} catch {
return false;
}
});
effectiveBasePolicyPath = policyPath;
basePolicy = filtered.content;
}
}

const basePolicyNames = getNetworkPolicyNames(basePolicy);
if (basePolicyNames === null) {
return {
policyPath: effectiveBasePolicyPath,
appliedPresets: [],
cleanup: combinedCleanup,
cleanup: buildCleanup(),
};
}
const existingChannelPresets = activeMessagingChannels.filter((channel) =>
basePolicyNames.has(channel),
);

const basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8");
const basePolicyNames = getNetworkPolicyNames(basePolicy);
if (basePolicyNames === null) {
if (requestedCreateTimePresets.length === 0) {
return {
policyPath: effectiveBasePolicyPath,
appliedPresets: [],
cleanup: combinedCleanup,
appliedPresets: dedupe(existingChannelPresets),
cleanup: buildCleanup(),
};
}

const existingCreateTimePresets = requestedCreateTimePresets.filter((preset) =>
basePolicyNames.has(preset),
);
Expand All @@ -212,8 +286,8 @@ export function prepareInitialSandboxCreatePolicy(
if (createTimePresets.length === 0) {
return {
policyPath: effectiveBasePolicyPath,
appliedPresets: existingCreateTimePresets,
cleanup: combinedCleanup,
appliedPresets: dedupe([...existingChannelPresets, ...existingCreateTimePresets]),
cleanup: buildCleanup(),
};
}

Expand All @@ -237,7 +311,11 @@ export function prepareInitialSandboxCreatePolicy(

return {
policyPath,
appliedPresets: [...existingCreateTimePresets, ...mergedPolicy.appliedPresets],
cleanup: () => cleanupFns.map((cleanup) => cleanup()).every(Boolean),
appliedPresets: dedupe([
...existingChannelPresets,
...existingCreateTimePresets,
...mergedPolicy.appliedPresets,
]),
cleanup: buildCleanup(),
};
}
104 changes: 100 additions & 4 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,102 @@ function loadPreset(name: string): string | null {
return fs.readFileSync(file, "utf-8");
}

function isPolicyObject(value: PolicyValue): value is PolicyObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseNetworkPolicies(content: string | null | undefined): PolicyObject | null {
if (!content) return null;
try {
const parsed = YAML.parse(content);
const networkPolicies = isPolicyDocument(parsed) ? parsed.network_policies : null;
return isPolicyObject(networkPolicies) ? networkPolicies : null;
} catch {
return null;
}
}

function parsePresetPolicyKeys(presetContent: string | null | undefined): string[] {
const presetEntries = extractPresetEntries(presetContent);
if (!presetEntries) return [];
return Object.keys(parseNetworkPolicies(`network_policies:\n${presetEntries}`) || {});
}

const AGENT_PRESET_KEY_ALIASES: Record<string, string[]> = {
wechat: ["wechat_bridge"],
};

function selectAgentPolicyKeys(
agentPolicies: PolicyObject,
presetName: string,
builtinPresetContent: string,
): string[] {
const builtinKeys = parsePresetPolicyKeys(builtinPresetContent);
if (
builtinKeys.length > 0 &&
builtinKeys.every((key) => Object.prototype.hasOwnProperty.call(agentPolicies, key))
) {
return builtinKeys;
}

if (Object.prototype.hasOwnProperty.call(agentPolicies, presetName)) {
return [presetName];
}

const aliases = AGENT_PRESET_KEY_ALIASES[presetName] || [];
const aliasMatches = aliases.filter((key) =>
Object.prototype.hasOwnProperty.call(agentPolicies, key),
);
if (aliasMatches.length > 0) return aliasMatches;

return Object.entries(agentPolicies)
.filter(([, value]) => isPolicyObject(value) && value.name === presetName)
.map(([key]) => key);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function loadAgentPresetContent(
sandboxName: string,
presetName: string,
builtinPresetContent: string,
): string | null {
try {
const sandbox = registry.getSandbox(sandboxName);
if (!sandbox?.agent) return null;

const agent = loadAgent(sandbox.agent);
if (!agent?.policyAdditionsPath || !fs.existsSync(agent.policyAdditionsPath)) return null;

const agentPolicies = parseNetworkPolicies(
fs.readFileSync(agent.policyAdditionsPath, "utf-8"),
);
if (!agentPolicies) return null;

const keys = selectAgentPolicyKeys(agentPolicies, presetName, builtinPresetContent);
if (keys.length === 0) return null;

const selectedPolicies: PolicyObject = {};
for (const key of keys) selectedPolicies[key] = agentPolicies[key];

return YAML.stringify({
preset: {
name: presetName,
description: `${agent.displayName} ${presetName} policy`,
},
network_policies: selectedPolicies,
});
} catch {
return null;
}
}

function loadPresetForSandbox(sandboxName: string, presetName: string): string | null {
const builtinPresetContent = loadPreset(presetName);
if (!builtinPresetContent) return null;
return (
loadAgentPresetContent(sandboxName, presetName, builtinPresetContent) || builtinPresetContent
);
}

/**
* Extract the bare hostnames declared in a preset YAML (anything matched by
* `host: <value>`), with surrounding quotes stripped. Used to show the
Expand Down Expand Up @@ -440,7 +536,7 @@ function removePreset(sandboxName: string, presetName: string): boolean {
// Resolve preset content: built-in first, then custom presets persisted
// in the registry. `isCustom` controls which registry bucket to prune on
// success.
let presetContent: string | null = loadPreset(presetName);
let presetContent: string | null = loadPresetForSandbox(sandboxName, presetName);
let isCustom = false;
if (!presetContent) {
const custom = registry
Expand Down Expand Up @@ -679,7 +775,7 @@ function applyPreset(
presetName: string,
options: Record<string, unknown> = {},
): boolean {
const presetContent = loadPreset(presetName);
const presetContent = loadPresetForSandbox(sandboxName, presetName);
if (!presetContent) {
console.error(` Cannot load preset: ${presetName}`);
return false;
Expand Down Expand Up @@ -716,7 +812,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean {
const endpointLogs: string[][] = [];

for (const presetName of uniquePresetNames) {
const presetContent = loadPreset(presetName);
const presetContent = loadPresetForSandbox(sandboxName, presetName);
if (!presetContent) {
console.error(` Cannot load preset: ${presetName}`);
return false;
Expand Down Expand Up @@ -947,7 +1043,7 @@ function getGatewayPresets(sandboxName: string): string[] | null {
const matched = [];

for (const preset of listPresets()) {
const content = loadPreset(preset.name);
const content = loadPresetForSandbox(sandboxName, preset.name);
if (!content) continue;
const entries = extractPresetEntries(content);
if (!entries) continue;
Expand Down
Loading
Loading