Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5261,6 +5261,7 @@ async function createSandbox(
directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled,
dockerGpuPatch: useDockerGpuPatch,
additionalPresets: hermesToolGateways,
agentName: agent?.name || null,
},
);
if (initialSandboxPolicy.cleanup) {
Expand Down
36 changes: 36 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,42 @@ 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("merges missing create-time presets into a temporary policy", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n");

Expand Down
102 changes: 87 additions & 15 deletions src/lib/onboard/initial-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 +168,58 @@ 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,
};
}

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 +230,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") {
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 +280,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 +305,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(),
};
}
94 changes: 90 additions & 4 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,92 @@ 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}`) || {});
}

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];
}

return Object.entries(agentPolicies)
.filter(([, value]) => isPolicyObject(value) && value.name === presetName)
.map(([key]) => key);
}

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 +526,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 +765,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 +802,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 +1033,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