Skip to content
2 changes: 2 additions & 0 deletions docs/network-policy/customize-network-policy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ Custom presets applied with `--from-file` or `--from-dir` are recorded in the Ne
$ nemoclaw my-assistant policy-remove my-internal-api --yes
```

NemoClaw also uses the stored YAML content when you run `nemoclaw <name> rebuild`, so custom presets survive rebuild even if the original preset file is no longer on disk.

`policy-remove` accepts both built-in and custom preset names. Run `nemoclaw <name> policy-list` to see every preset currently applied to the sandbox.

## Related Topics
Expand Down
1 change: 0 additions & 1 deletion nemoclaw-blueprint/router/llm-router
Submodule llm-router deleted from 2bd8df
143 changes: 110 additions & 33 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,112 @@ function _rebuildLog(msg: string) {
console.error(` ${D}[rebuild ${new Date().toISOString()}] ${redact(msg)}${R}`);
}

export interface RestorePolicyPresetsResult {
restoredPresets: string[];
failedPresets: string[];
restoredCustomPresets: string[];
failedCustomPresets: string[];
}

interface RestorePolicyPresetsDeps {
applyPreset?: typeof policies.applyPreset;
applyPresetContent?: typeof policies.applyPresetContent;
disabledChannels?: string[];
log?: (msg: string) => void;
stdout?: { log: (...args: unknown[]) => void };
stderr?: { error: (...args: unknown[]) => void };
}

export function restorePolicyPresetsFromManifest(
sandboxName: string,
backupManifest: sandboxState.RebuildManifest,
deps: RestorePolicyPresetsDeps = {},
): RestorePolicyPresetsResult {
const applyPreset = deps.applyPreset ?? policies.applyPreset;
const applyPresetContent = deps.applyPresetContent ?? policies.applyPresetContent;
const log = deps.log ?? (() => {});
const stdout = deps.stdout ?? console;
const stderr = deps.stderr ?? console;

const savedPresets = pruneDisabledMessagingPolicyPresets(
backupManifest.policyPresets || [],
deps.disabledChannels,
);
const savedCustomPresets = backupManifest.customPolicyPresets || [];
const restoredPresets: string[] = [];
const failedPresets: string[] = [];
const restoredCustomPresets: string[] = [];
const failedCustomPresets: string[] = [];

if (savedPresets.length === 0 && savedCustomPresets.length === 0) {
return { restoredPresets, failedPresets, restoredCustomPresets, failedCustomPresets };
}

stdout.log("");
stdout.log(" Restoring policy presets...");

if (savedPresets.length > 0) {
log(`Policy presets to restore: [${savedPresets.join(",")}]`);
}
for (const presetName of savedPresets) {
try {
log(`Applying preset: ${presetName}`);
const applied = applyPreset(sandboxName, presetName);
if (applied) {
restoredPresets.push(presetName);
} else {
failedPresets.push(presetName);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Failed to apply preset '${presetName}': ${errorMessage}`);
failedPresets.push(presetName);
}
}

if (savedCustomPresets.length > 0) {
log(`Custom policy presets to restore: [${savedCustomPresets.map((p) => p.name).join(",")}]`);
}
for (const preset of savedCustomPresets) {
try {
log(`Applying custom preset: ${preset.name}`);
const applied = applyPresetContent(sandboxName, preset.name, preset.content, {
custom: { sourcePath: preset.sourcePath },
});
if (applied) {
restoredCustomPresets.push(preset.name);
} else {
failedCustomPresets.push(preset.name);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Failed to apply custom preset '${preset.name}': ${errorMessage}`);
failedCustomPresets.push(preset.name);
}
}

if (restoredPresets.length > 0) {
stdout.log(` ${G}\u2713${R} Policy presets restored: ${restoredPresets.join(", ")}`);
}
if (restoredCustomPresets.length > 0) {
stdout.log(
` ${G}\u2713${R} Custom policy presets restored: ${restoredCustomPresets.join(", ")}`,
);
}
if (failedPresets.length > 0) {
stderr.error(` ${YW}\u26a0${R} Failed to restore presets: ${failedPresets.join(", ")}`);
stderr.error(` Re-apply manually with: ${CLI_NAME} ${sandboxName} policy-add`);
}
if (failedCustomPresets.length > 0) {
stderr.error(
` ${YW}\u26a0${R} Failed to restore custom presets: ${failedCustomPresets.join(", ")}`,
);
stderr.error(` Re-apply manually with: ${CLI_NAME} ${sandboxName} policy-add --from-file`);
}

return { restoredPresets, failedPresets, restoredCustomPresets, failedCustomPresets };
}

/**
* Resolve the credential environment variable required to recreate a sandbox.
*/
Expand Down Expand Up @@ -761,39 +867,10 @@ export async function rebuildSandbox(
// Policy presets live in the gateway policy engine, not the sandbox filesystem.
// They are lost when the sandbox is destroyed and recreated. Re-apply any
// presets that were captured in the backup manifest.
const savedPresets = pruneDisabledMessagingPolicyPresets(
backupManifest.policyPresets || [],
rebuildDisabledChannels,
);
if (savedPresets.length > 0) {
console.log("");
console.log(" Restoring policy presets...");
log(`Policy presets to restore: [${savedPresets.join(",")}]`);
const restoredPresets: string[] = [];
const failedPresets: string[] = [];
for (const presetName of savedPresets) {
try {
log(`Applying preset: ${presetName}`);
const applied = policies.applyPreset(sandboxName, presetName);
if (applied) {
restoredPresets.push(presetName);
} else {
failedPresets.push(presetName);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Failed to apply preset '${presetName}': ${errorMessage}`);
failedPresets.push(presetName);
}
}
if (restoredPresets.length > 0) {
console.log(` ${G}\u2713${R} Policy presets restored: ${restoredPresets.join(", ")}`);
}
if (failedPresets.length > 0) {
console.error(` ${YW}\u26a0${R} Failed to restore presets: ${failedPresets.join(", ")}`);
console.error(` Re-apply manually with: ${CLI_NAME} ${sandboxName} policy-add`);
}
}
restorePolicyPresetsFromManifest(sandboxName, backupManifest, {
disabledChannels: rebuildDisabledChannels,
log,
});

// Step 6: Post-restore agent-specific migration
const rebuiltAgent = agentRuntime.getSessionAgent(sandboxName);
Expand Down
39 changes: 38 additions & 1 deletion src/lib/state/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { loadAgent } from "../agent/defs.js";
import { shellQuote } from "../runner.js";
import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js";
import * as registry from "./registry.js";
import type { CustomPolicyEntry, SandboxEntry } from "./registry.js";

const HOME_DIR = path.resolve(process.env.HOME || os.homedir());
const REBUILD_BACKUPS_DIR = path.join(HOME_DIR, ".nemoclaw", "rebuild-backups");
Expand Down Expand Up @@ -63,11 +64,19 @@ export interface RebuildManifest {
backupPath: string;
blueprintDigest: string | null;
policyPresets?: string[];
customPolicyPresets?: RebuildCustomPolicyPreset[];
instances?: InstanceBackup[];
// Optional user-provided label for `snapshot restore <name>`.
name?: string;
}

export interface RebuildCustomPolicyPreset {
name: string;
content: string;
sourcePath?: string;
appliedAt?: string;
}

// Manifest enriched with a virtual version number computed at list time.
// Versions are position-based (v1 = oldest by timestamp) and NOT persisted,
// so they can shift if snapshots are deleted.
Expand Down Expand Up @@ -135,6 +144,16 @@ function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}

function isRebuildCustomPolicyPreset(value: unknown): value is RebuildCustomPolicyPreset {
return (
isRecord(value) &&
typeof value.name === "string" &&
typeof value.content === "string" &&
(value.sourcePath === undefined || typeof value.sourcePath === "string") &&
(value.appliedAt === undefined || typeof value.appliedAt === "string")
);
}

function isStateFileSpec(value: unknown): value is StateFileSpec {
return (
isRecord(value) &&
Expand Down Expand Up @@ -173,13 +192,29 @@ function isRebuildManifest(value: unknown): value is RebuildManifest {
value.blueprintDigest === null ||
typeof value.blueprintDigest === "string") &&
(value.policyPresets === undefined || isStringArray(value.policyPresets)) &&
(value.customPolicyPresets === undefined ||
(Array.isArray(value.customPolicyPresets) &&
value.customPolicyPresets.every(isRebuildCustomPolicyPreset))) &&
(value.instances === undefined ||
(Array.isArray(value.instances) &&
value.instances.every((entry) => isInstanceBackup(entry)))) &&
(value.name === undefined || typeof value.name === "string")
);
}

export function getPolicyPresetsForManifest(sb: SandboxEntry | null): {
policyPresets: string[];
customPolicyPresets: RebuildCustomPolicyPreset[];
} {
return {
policyPresets: sb?.policies && sb.policies.length > 0 ? [...sb.policies] : [],
customPolicyPresets:
sb?.customPolicies && sb.customPolicies.length > 0
? sb.customPolicies.map((entry: CustomPolicyEntry) => ({ ...entry }))
: [],
};
}

// ── Safe tar extraction ──────────────────────────────────────────

/**
Expand Down Expand Up @@ -1003,8 +1038,9 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions =
// Capture applied policy presets from the registry so they can be
// re-applied after rebuild. Presets live in the gateway policy engine,
// not on the sandbox filesystem, so they are lost on destroy/recreate.
const policyPresets: string[] = sb?.policies && sb.policies.length > 0 ? [...sb.policies] : [];
const { policyPresets, customPolicyPresets } = getPolicyPresetsForManifest(sb);
_log(`policyPresets from registry: [${policyPresets.join(",")}]`);
_log(`customPolicyPresets from registry: [${customPolicyPresets.map((p) => p.name).join(",")}]`);

const manifest: RebuildManifest = {
version: MANIFEST_VERSION,
Expand All @@ -1019,6 +1055,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions =
backupPath,
blueprintDigest: computeBlueprintDigest(),
policyPresets,
customPolicyPresets,
...(providedName !== null ? { name: providedName } : {}),
};

Expand Down
Loading