From 6b9aa3a65782aba832e23a0b070b6c90fd25b551 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 21 May 2026 14:56:37 +0700 Subject: [PATCH 1/2] fix(policy): scope Hermes messaging policies Signed-off-by: San Dang --- agents/hermes/policy-additions.yaml | 6 +- src/lib/onboard/initial-policy.test.ts | 36 +++++++++ src/lib/onboard/initial-policy.ts | 108 +++++++++++++++++++++---- src/lib/policy/index.ts | 94 ++++++++++++++++++++- test/policies.test.ts | 83 +++++++++++++++++++ 5 files changed, 307 insertions(+), 20 deletions(-) diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index d2ab1571825..d5ed0708e86 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -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: diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 833852710cb..729e3357c1f 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -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"); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 95f6a487081..1c8d1b78e44 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -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"; @@ -17,6 +18,13 @@ const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record = { slack: ["slack"], }; +const HERMES_MESSAGING_POLICY_KEYS: Record = { + 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"]; @@ -161,18 +169,63 @@ export function getNetworkPolicyNames(policyContent: string): Set | null } } +function isYamlObject(value: unknown): value is Record { + 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 normalized.endsWith("/agents/hermes/policy-additions.yaml"); +} + 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( [ @@ -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), ); @@ -212,8 +286,8 @@ export function prepareInitialSandboxCreatePolicy( if (createTimePresets.length === 0) { return { policyPath: effectiveBasePolicyPath, - appliedPresets: existingCreateTimePresets, - cleanup: combinedCleanup, + appliedPresets: dedupe([...existingChannelPresets, ...existingCreateTimePresets]), + cleanup: buildCleanup(), }; } @@ -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(), }; } diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 70f403f8da0..526e5d48e30 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -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: `), with surrounding quotes stripped. Used to show the @@ -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 @@ -679,7 +765,7 @@ function applyPreset( presetName: string, options: Record = {}, ): boolean { - const presetContent = loadPreset(presetName); + const presetContent = loadPresetForSandbox(sandboxName, presetName); if (!presetContent) { console.error(` Cannot load preset: ${presetName}`); return false; @@ -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; @@ -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; diff --git a/test/policies.test.ts b/test/policies.test.ts index f3b3f3051b3..de9630f6832 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -534,6 +534,89 @@ exit 1 fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("uses agent-specific preset content for Hermes Discord", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-hermes-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const policyOut = path.join(tmpDir, "policy.yaml"); + const script = String.raw` +const fs = require("node:fs"); +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes", policies: [] }); +const result = policies.applyPresets("hermes-sandbox", ["discord"]); +process.stdout.write("\n__RESULT__" + JSON.stringify({ + result, + policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), + registry: registry.getSandbox("hermes-sandbox"), +})); +`; + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1 $2" = "policy get" ]; then + printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' + exit 0 +fi +if [ "$1 $2" = "policy set" ]; then + policy_file="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--policy" ]; then + policy_file="$2" + break + fi + shift + done + cp "$policy_file" ${JSON.stringify(policyOut)} + printf 'Policy version 2 submitted\nPolicy version 2 loaded\n' + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync(process.execPath, ["-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, + POLICY_OUT: policyOut, + }, + }); + + expect(result.status).toBe(0); + const marker = "__RESULT__"; + const markerIndex = result.stdout.indexOf(marker); + expect(markerIndex).toBeGreaterThanOrEqual(0); + const payload = JSON.parse(result.stdout.slice(markerIndex + marker.length)); + const parsed = YAML.parse(payload.policy); + const discordPolicy = parsed.network_policies.discord; + const binaries = discordPolicy.binaries.map((entry: { path: string }) => entry.path); + expect(binaries).toContain("/usr/bin/python3*"); + expect(binaries).toContain("/opt/hermes/.venv/bin/python"); + const discordCom = discordPolicy.endpoints.find( + (endpoint: { host?: string }) => endpoint.host === "discord.com", + ); + const mutationRules = discordCom.rules + .map((rule: { allow?: { method?: string; path?: string } }) => rule.allow) + .filter((rule: { method?: string } | undefined) => + ["PUT", "PATCH", "DELETE"].includes(rule?.method || ""), + ); + expect(mutationRules).toContainEqual({ + method: "PATCH", + path: "/api/v*/channels/*/messages/*", + }); + expect(mutationRules).not.toContainEqual({ method: "PATCH", path: "/**" }); + expect(payload.registry.policies).toEqual(["discord"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe("applyPreset disclosure logging", () => { From 6024262a5148bd5ef7b0d3b56b99ac09e70ef108 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 21 May 2026 15:50:43 +0700 Subject: [PATCH 2/2] fix(policy): handle Hermes policy aliases Signed-off-by: San Dang --- src/lib/onboard/initial-policy.test.ts | 17 ++++++ src/lib/onboard/initial-policy.ts | 2 +- src/lib/policy/index.ts | 10 ++++ test/policies.test.ts | 71 ++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 729e3357c1f..0289b07afbd 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -190,6 +190,23 @@ network_policies: 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"); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 1c8d1b78e44..51cc2bc78eb 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -202,7 +202,7 @@ function filterHermesInactiveMessagingPolicies( function isHermesPolicyPath(policyPath: string): boolean { const normalized = policyPath.split(path.sep).join("/"); - return normalized.endsWith("/agents/hermes/policy-additions.yaml"); + return /(^|\/)agents\/hermes\/policy-additions\.yaml$/.test(normalized); } export function prepareInitialSandboxCreatePolicy( diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 526e5d48e30..8b9cd42ff03 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -105,6 +105,10 @@ function parsePresetPolicyKeys(presetContent: string | null | undefined): string return Object.keys(parseNetworkPolicies(`network_policies:\n${presetEntries}`) || {}); } +const AGENT_PRESET_KEY_ALIASES: Record = { + wechat: ["wechat_bridge"], +}; + function selectAgentPolicyKeys( agentPolicies: PolicyObject, presetName: string, @@ -122,6 +126,12 @@ function selectAgentPolicyKeys( 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); diff --git a/test/policies.test.ts b/test/policies.test.ts index de9630f6832..53c9693e7d9 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -617,6 +617,77 @@ exit 1 fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("uses agent-specific preset aliases for Hermes WeChat", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-hermes-wechat-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const policyOut = path.join(tmpDir, "policy.yaml"); + const script = String.raw` +const fs = require("node:fs"); +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes", policies: [] }); +const result = policies.applyPresets("hermes-sandbox", ["wechat"]); +process.stdout.write("\n__RESULT__" + JSON.stringify({ + result, + policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), + registry: registry.getSandbox("hermes-sandbox"), +})); +`; + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1 $2" = "policy get" ]; then + printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' + exit 0 +fi +if [ "$1 $2" = "policy set" ]; then + policy_file="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--policy" ]; then + policy_file="$2" + break + fi + shift + done + cp "$policy_file" ${JSON.stringify(policyOut)} + printf 'Policy version 2 submitted\nPolicy version 2 loaded\n' + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync(process.execPath, ["-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, + POLICY_OUT: policyOut, + }, + }); + + expect(result.status).toBe(0); + const marker = "__RESULT__"; + const markerIndex = result.stdout.indexOf(marker); + expect(markerIndex).toBeGreaterThanOrEqual(0); + const payload = JSON.parse(result.stdout.slice(markerIndex + marker.length)); + const parsed = YAML.parse(payload.policy); + expect(parsed.network_policies.wechat).toBeUndefined(); + const wechatPolicy = parsed.network_policies.wechat_bridge; + const binaries = wechatPolicy.binaries.map((entry: { path: string }) => entry.path); + expect(binaries).toContain("/usr/bin/python3*"); + expect(binaries).toContain("/opt/hermes/.venv/bin/python"); + expect(payload.registry.policies).toEqual(["wechat"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe("applyPreset disclosure logging", () => {