From 4f8d9a4035baa4bb130ad11d34e92bef6da49fae Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 25 Mar 2026 11:12:37 -0700 Subject: [PATCH 1/6] fix(security): strip credentials from migration snapshots and enforce blueprint digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter auth-profiles.json from snapshot bundles during createSnapshotBundle() - Strip gateway config (contains auth tokens) from sandbox openclaw.json - Add blueprint digest verification: SHA-256 recorded at snapshot time, validated on restore - Bump SNAPSHOT_VERSION 2 → 3 Closes #156 --- nemoclaw/src/commands/migration-state.test.ts | 355 +++++++++++++++++- nemoclaw/src/commands/migration-state.ts | 66 +++- test/policies.test.js | 52 ++- 3 files changed, 455 insertions(+), 18 deletions(-) diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index 55252dcb6..8934d3171 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -48,10 +48,11 @@ vi.mock("node:fs", async (importOriginal) => { if (!entry) throw new Error(`ENOENT: ${src}`); store.set(dest, { ...entry }); }), - cpSync: vi.fn((src: string, dest: string) => { + cpSync: vi.fn((src: string, dest: string, opts?: { filter?: (source: string) => boolean }) => { // Shallow copy: copy all entries whose path starts with src for (const [k, v] of store) { if (k === src || k.startsWith(src + "/")) { + if (opts?.filter && !opts.filter(k)) continue; const relative = k.slice(src.length); store.set(dest + relative, { ...v }); } @@ -455,7 +456,7 @@ describe("commands/migration-state", () => { expect.unreachable("bundle should not be null"); return; } - expect(bundle.manifest.version).toBe(2); + expect(bundle.manifest.version).toBe(3); expect(bundle.manifest.homeDir).toBe("/home/user"); expect(bundle.temporary).toBe(false); }); @@ -529,6 +530,156 @@ describe("commands/migration-state", () => { } expect(bundle.manifest.externalRoots.length).toBe(1); }); + + it("excludes auth-profiles.json from snapshot", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + addDir("/home/user/.openclaw/agents/main/agent"); + addFile( + "/home/user/.openclaw/agents/main/agent/auth-profiles.json", + JSON.stringify({ "nvidia:manual": { type: "api_key" } }), + ); + addFile( + "/home/user/.openclaw/agents/main/agent/config.json", + JSON.stringify({ name: "main" }), + ); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + + // auth-profiles.json should not exist anywhere in the snapshot + const snapshotKeys = [...store.keys()].filter((k) => k.startsWith(bundle.snapshotDir)); + const authProfileKeys = snapshotKeys.filter((k) => k.endsWith("auth-profiles.json")); + expect(authProfileKeys).toHaveLength(0); + + // config.json should still be present + const configKeys = snapshotKeys.filter((k) => k.endsWith("agents/main/agent/config.json")); + expect(configKeys.length).toBeGreaterThan(0); + }); + + it("strips gateway key from sandbox openclaw.json", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile( + "/home/user/.openclaw/openclaw.json", + JSON.stringify({ version: 1, gateway: { auth: { token: "secret123" } } }), + ); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + + // Read the sandbox-bundle openclaw.json + const sandboxConfigEntry = store.get(bundle.preparedStateDir + "/openclaw.json"); + if (!sandboxConfigEntry?.content) { + expect.unreachable("sandbox config entry should exist with content"); + return; + } + const sandboxConfig = JSON.parse(sandboxConfigEntry.content); + expect(sandboxConfig).not.toHaveProperty("gateway"); + expect(sandboxConfig).toHaveProperty("version", 1); + }); + + it("records blueprintDigest when blueprintPath is provided", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + addFile("/test/blueprint.yaml", "version: 0.1.0\ndigest: ''\n"); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { + persist: false, + blueprintPath: "/test/blueprint.yaml", + }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + expect(typeof bundle.manifest.blueprintDigest).toBe("string"); + expect((bundle.manifest.blueprintDigest ?? "").length).toBeGreaterThan(0); + }); + + it("blueprintDigest is null when no blueprintPath given", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + + const bundle = createSnapshotBundle(hostState, logger, { persist: false }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + expect(bundle.manifest.blueprintDigest).toBeNull(); + }); }); // ------------------------------------------------------------------------- @@ -896,5 +1047,205 @@ describe("commands/migration-state", () => { } } }); + + it("restore succeeds when blueprint digest matches", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + // Create a blueprint file and compute its expected digest + const blueprintContent = "version: 0.1.0\ndigest: ''\n"; + addFile("/test/blueprint.yaml", blueprintContent); + + // First create a snapshot with blueprintPath to get the real digest + addDir("/home/user/.openclaw"); + addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); + const hostState: HostOpenClawState = { + exists: true, + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configDir: "/home/user/.openclaw", + configPath: "/home/user/.openclaw/openclaw.json", + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; + const bundle = createSnapshotBundle(hostState, logger, { + persist: false, + blueprintPath: "/test/blueprint.yaml", + }); + if (bundle === null) { + expect.unreachable("bundle should not be null"); + return; + } + const digest = bundle.manifest.blueprintDigest; + expect(digest).toBeTruthy(); + + // Now set up for restore with matching digest + store.clear(); + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: digest, + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/snapshots/snap1/openclaw/openclaw.json", JSON.stringify({ restored: true })); + addFile("/test/blueprint.yaml", blueprintContent); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger, { + blueprintPath: "/test/blueprint.yaml", + }); + expect(result).toBe(true); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore fails when blueprint digest mismatches", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: "wrong-hash-value", + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/test/blueprint.yaml", "version: 0.1.0\n"); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger, { + blueprintPath: "/test/blueprint.yaml", + }); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("digest mismatch")); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore fails when manifest has empty string blueprintDigest", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: "", + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("invalid blueprintDigest"), + ); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore fails when manifest has digest but no blueprintPath provided", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + blueprintDigest: "abc123", + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("no blueprint is available"), + ); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); + + it("restore succeeds when manifest has no blueprintDigest (backward compat)", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + const manifest: SnapshotManifest = { + version: 2, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + // no blueprintDigest field + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/snapshots/snap1/openclaw/openclaw.json", JSON.stringify({ restored: true })); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(true); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); }); }); diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index 258ac89ec..4a53af0b1 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -17,11 +17,12 @@ import { import os from "node:os"; import path from "node:path"; import { create as createTar } from "tar"; +import { createHash } from "node:crypto"; import JSON5 from "json5"; import type { PluginLogger } from "../index.js"; const SANDBOX_MIGRATION_DIR = "/sandbox/.nemoclaw/migration"; -const SNAPSHOT_VERSION = 2; +const SNAPSHOT_VERSION = 3; export type MigrationRootKind = "workspace" | "agentDir" | "skillsExtraDir"; @@ -65,6 +66,7 @@ export interface SnapshotManifest { hasExternalConfig: boolean; externalRoots: MigrationExternalRoot[]; warnings: string[]; + blueprintDigest?: string | null; } export interface SnapshotBundle { @@ -474,9 +476,28 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp }; } -function copyDirectory(sourcePath: string, destinationPath: string): void { +function computeFileDigest(filePath: string): string | null { + if (!existsSync(filePath)) return null; + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +/** + * Basenames that MUST NOT be copied into snapshot bundles. + * These files contain credential references or session tokens + * that should never cross the sandbox boundary. + */ +const CREDENTIAL_SENSITIVE_BASENAMES = new Set(["auth-profiles.json"]); + +function copyDirectory( + sourcePath: string, + destinationPath: string, + options?: { stripCredentials?: boolean }, +): void { cpSync(sourcePath, destinationPath, { recursive: true, + filter: options?.stripCredentials + ? (source: string) => !CREDENTIAL_SENSITIVE_BASENAMES.has(path.basename(source)) + : undefined, }); } @@ -560,6 +581,9 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s } } + // Strip gateway config (contains auth tokens) — sandbox entrypoint regenerates it + delete (config as Record)["gateway"]; + const configPath = path.join(preparedStateDir, "openclaw.json"); writeFileSync(configPath, JSON.stringify(config, null, 2)); chmodSync(configPath, 0o600); @@ -569,7 +593,7 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s export function createSnapshotBundle( hostState: HostOpenClawState, logger: PluginLogger, - options: { persist: boolean }, + options: { persist: boolean; blueprintPath?: string }, ): SnapshotBundle | null { if (!hostState.stateDir || !hostState.homeDir) { logger.error("Cannot snapshot host OpenClaw state: no state directory was resolved."); @@ -587,7 +611,7 @@ export function createSnapshotBundle( try { mkdirSync(parentDir, { recursive: true }); const snapshotStateDir = path.join(parentDir, "openclaw"); - copyDirectory(hostState.stateDir, snapshotStateDir); + copyDirectory(hostState.stateDir, snapshotStateDir, { stripCredentials: true }); if (hostState.configPath && hostState.hasExternalConfig) { const configSnapshotDir = path.join(parentDir, "config"); @@ -601,13 +625,15 @@ export function createSnapshotBundle( for (const root of hostState.externalRoots) { const destination = path.join(parentDir, root.snapshotRelativePath); mkdirSync(path.dirname(destination), { recursive: true }); - copyDirectory(root.sourcePath, destination); + copyDirectory(root.sourcePath, destination, { stripCredentials: true }); externalRoots.push({ ...root, symlinkPaths: collectSymlinkPaths(root.sourcePath), }); } + const blueprintDigest = options.blueprintPath ? computeFileDigest(options.blueprintPath) : null; + const manifest: SnapshotManifest = { version: SNAPSHOT_VERSION, createdAt: new Date().toISOString(), @@ -617,6 +643,7 @@ export function createSnapshotBundle( hasExternalConfig: hostState.hasExternalConfig, externalRoots, warnings: hostState.warnings, + blueprintDigest, }; writeSnapshotManifest(parentDir, manifest); @@ -663,7 +690,11 @@ export function loadSnapshotManifest(snapshotDir: string): SnapshotManifest { return readSnapshotManifest(snapshotDir); } -export function restoreSnapshotToHost(snapshotDir: string, logger: PluginLogger): boolean { +export function restoreSnapshotToHost( + snapshotDir: string, + logger: PluginLogger, + options?: { blueprintPath?: string }, +): boolean { const manifest = readSnapshotManifest(snapshotDir); const snapshotStateDir = path.join(snapshotDir, "openclaw"); if (!existsSync(snapshotStateDir)) { @@ -740,6 +771,29 @@ export function restoreSnapshotToHost(snapshotDir: string, logger: PluginLogger) } } + // SECURITY: Validate blueprint digest when present in manifest + if (manifest.blueprintDigest != null) { + if (!manifest.blueprintDigest || typeof manifest.blueprintDigest !== "string") { + logger.error("Snapshot manifest has invalid blueprintDigest. Refusing to restore."); + return false; + } + const currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null; + if (!currentDigest) { + logger.error( + "Snapshot contains a blueprintDigest but no blueprint is available for verification. " + + "Refusing to restore.", + ); + return false; + } + if (currentDigest !== manifest.blueprintDigest) { + logger.error( + `Blueprint digest mismatch. Snapshot was created with digest=${manifest.blueprintDigest} ` + + `but current blueprint has digest=${currentDigest}. Refusing to restore.`, + ); + return false; + } + } + try { if (existsSync(manifest.stateDir)) { const archiveName = `${manifest.stateDir}.nemoclaw-archived-${String(Date.now())}`; diff --git a/test/policies.test.js b/test/policies.test.js index 5b3782c03..1671b7726 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -20,8 +20,21 @@ describe("policies", () => { }); it("returns expected preset names", () => { - const names = policies.listPresets().map((p) => p.name).sort(); - const expected = ["discord", "docker", "huggingface", "jira", "npm", "outlook", "pypi", "slack", "telegram"]; + const names = policies + .listPresets() + .map((p) => p.name) + .sort(); + const expected = [ + "discord", + "docker", + "huggingface", + "jira", + "npm", + "outlook", + "pypi", + "slack", + "telegram", + ]; expect(names).toEqual(expected); }); }); @@ -70,17 +83,28 @@ describe("policies", () => { describe("buildPolicySetCommand", () => { it("shell-quotes sandbox name to prevent injection", () => { - const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); - expect(cmd).toBe("openshell policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'"); + const cmd = policies.buildPolicySetCommand( + "/tmp/policy.yaml", + "my-assistant", + ); + expect(cmd).toBe( + "openshell policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'", + ); }); it("escapes shell metacharacters in sandbox name", () => { - const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "test; whoami"); + const cmd = policies.buildPolicySetCommand( + "/tmp/policy.yaml", + "test; whoami", + ); expect(cmd.includes("'test; whoami'")).toBeTruthy(); }); it("places --wait before the sandbox name", () => { - const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "test-box"); + const cmd = policies.buildPolicySetCommand( + "/tmp/policy.yaml", + "test-box", + ); const waitIdx = cmd.indexOf("--wait"); const nameIdx = cmd.indexOf("'test-box'"); expect(waitIdx < nameIdx).toBeTruthy(); @@ -89,8 +113,14 @@ describe("policies", () => { it("uses the resolved openshell binary when provided by the installer path", () => { process.env.NEMOCLAW_OPENSHELL_BIN = "/tmp/fake path/openshell"; try { - const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); - assert.equal(cmd, "'/tmp/fake path/openshell' policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'"); + const cmd = policies.buildPolicySetCommand( + "/tmp/policy.yaml", + "my-assistant", + ); + assert.equal( + cmd, + "'/tmp/fake path/openshell' policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'", + ); } finally { delete process.env.NEMOCLAW_OPENSHELL_BIN; } @@ -100,7 +130,9 @@ describe("policies", () => { describe("buildPolicyGetCommand", () => { it("shell-quotes sandbox name", () => { const cmd = policies.buildPolicyGetCommand("my-assistant"); - expect(cmd).toBe("openshell policy get --full 'my-assistant' 2>/dev/null"); + expect(cmd).toBe( + "openshell policy get --full 'my-assistant' 2>/dev/null", + ); }); }); @@ -116,7 +148,7 @@ describe("policies", () => { // rules: at 8+ space indent (inside an endpoint) is correct if (/^\s{4}rules:/.test(line)) { expect.unreachable( - `${p.name} line ${i + 1}: rules at policy level (should be inside endpoint)` + `${p.name} line ${i + 1}: rules at policy level (should be inside endpoint)`, ); } } From 5d2440f505f507876262f23f7b66b818d6a91b26 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 25 Mar 2026 11:42:00 -0700 Subject: [PATCH 2/6] fix(security): fail closed on v3 blueprint digest and normalize credential filter - computeFileDigest: catch FS errors instead of letting readFileSync throw - createSnapshotBundle: omit blueprintDigest from manifest when no blueprintPath; throw if blueprintPath provided but file is missing/unreadable - restoreSnapshotToHost: gate verification on manifest.version >= 3 instead of != null so tampered/null digests in v3 manifests fail closed - copyDirectory: lowercase basename before CREDENTIAL_SENSITIVE_BASENAMES lookup to prevent case-variant bypasses on case-insensitive filesystems --- nemoclaw/src/commands/migration-state.test.ts | 4 +-- nemoclaw/src/commands/migration-state.ts | 35 ++++++++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index 8934d3171..91d7ec8d8 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -652,7 +652,7 @@ describe("commands/migration-state", () => { expect((bundle.manifest.blueprintDigest ?? "").length).toBeGreaterThan(0); }); - it("blueprintDigest is null when no blueprintPath given", () => { + it("blueprintDigest is undefined when no blueprintPath given", () => { const logger = makeLogger(); addDir("/home/user/.openclaw"); addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 })); @@ -678,7 +678,7 @@ describe("commands/migration-state", () => { expect.unreachable("bundle should not be null"); return; } - expect(bundle.manifest.blueprintDigest).toBeNull(); + expect(bundle.manifest.blueprintDigest).toBeUndefined(); }); }); diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index 4a53af0b1..6235c60b1 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -477,8 +477,12 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp } function computeFileDigest(filePath: string): string | null { - if (!existsSync(filePath)) return null; - return createHash("sha256").update(readFileSync(filePath)).digest("hex"); + try { + if (!existsSync(filePath)) return null; + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); + } catch { + return null; + } } /** @@ -496,7 +500,8 @@ function copyDirectory( cpSync(sourcePath, destinationPath, { recursive: true, filter: options?.stripCredentials - ? (source: string) => !CREDENTIAL_SENSITIVE_BASENAMES.has(path.basename(source)) + ? (source: string) => + !CREDENTIAL_SENSITIVE_BASENAMES.has(path.basename(source).toLowerCase()) : undefined, }); } @@ -632,8 +637,6 @@ export function createSnapshotBundle( }); } - const blueprintDigest = options.blueprintPath ? computeFileDigest(options.blueprintPath) : null; - const manifest: SnapshotManifest = { version: SNAPSHOT_VERSION, createdAt: new Date().toISOString(), @@ -643,9 +646,19 @@ export function createSnapshotBundle( hasExternalConfig: hostState.hasExternalConfig, externalRoots, warnings: hostState.warnings, - blueprintDigest, }; + if (options.blueprintPath) { + const digest = computeFileDigest(options.blueprintPath); + if (!digest) { + throw new Error( + `Cannot compute blueprint digest for ${options.blueprintPath}. ` + + "The file may be missing or unreadable.", + ); + } + manifest.blueprintDigest = digest; + } + writeSnapshotManifest(parentDir, manifest); return { @@ -771,10 +784,14 @@ export function restoreSnapshotToHost( } } - // SECURITY: Validate blueprint digest when present in manifest - if (manifest.blueprintDigest != null) { + // SECURITY: Validate blueprint digest. + // v3+ snapshots MUST have a valid blueprintDigest — fail closed if missing/invalid. + // Legacy snapshots (version < 3) predate this field and skip verification. + if (manifest.version >= 3) { if (!manifest.blueprintDigest || typeof manifest.blueprintDigest !== "string") { - logger.error("Snapshot manifest has invalid blueprintDigest. Refusing to restore."); + logger.error( + "v3 snapshot manifest has missing or invalid blueprintDigest. Refusing to restore.", + ); return false; } const currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null; From 48f8833f3bff194907af70654ae0ebabfd22c5bf Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 25 Mar 2026 11:45:12 -0700 Subject: [PATCH 3/6] chore: fix prettier formatting --- nemoclaw/src/commands/migration-state.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index 6235c60b1..c94848035 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -500,8 +500,7 @@ function copyDirectory( cpSync(sourcePath, destinationPath, { recursive: true, filter: options?.stripCredentials - ? (source: string) => - !CREDENTIAL_SENSITIVE_BASENAMES.has(path.basename(source).toLowerCase()) + ? (source: string) => !CREDENTIAL_SENSITIVE_BASENAMES.has(path.basename(source).toLowerCase()) : undefined, }); } From 557dfa6c11ead21793067f7ddf132aa86d07931d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20Erickson=20=F0=9F=A6=9E?= Date: Wed, 25 Mar 2026 12:50:11 -0700 Subject: [PATCH 4/6] test: add v3 snapshot restore without blueprintPath --- nemoclaw/src/commands/migration-state.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index 91d7ec8d8..d8e2c307f 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -1247,5 +1247,37 @@ describe("commands/migration-state", () => { } } }); + + it("restore succeeds for v3 snapshot created without blueprintPath", () => { + const logger = makeLogger(); + const origHome = process.env.HOME; + process.env.HOME = "/home/user"; + try { + // v3 manifest with no blueprintDigest field — created without a blueprint + const manifest: SnapshotManifest = { + version: 3, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [], + warnings: [], + // blueprintDigest intentionally omitted + }; + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); + addDir("/snapshots/snap1/openclaw"); + addFile("/snapshots/snap1/openclaw/openclaw.json", JSON.stringify({ restored: true })); + + const result = restoreSnapshotToHost("/snapshots/snap1", logger); + expect(result).toBe(true); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + } + }); }); }); From 559996a2886b1399053f1dda4374d90459a99fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20Erickson=20=F0=9F=A6=9E?= Date: Wed, 25 Mar 2026 12:50:31 -0700 Subject: [PATCH 5/6] fix(security): fail closed only when blueprintDigest key is present --- nemoclaw/src/commands/migration-state.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index c94848035..aa1b4f777 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -784,12 +784,16 @@ export function restoreSnapshotToHost( } // SECURITY: Validate blueprint digest. - // v3+ snapshots MUST have a valid blueprintDigest — fail closed if missing/invalid. - // Legacy snapshots (version < 3) predate this field and skip verification. - if (manifest.version >= 3) { + // When a blueprintDigest is present in the manifest, it MUST be a non-empty + // string and MUST match the current blueprint — fail closed on mismatch, + // empty string, or null. Snapshots without a blueprintDigest (including all + // legacy v2 manifests and v3 snapshots created without a blueprint) skip + // verification. + if ("blueprintDigest" in manifest) { if (!manifest.blueprintDigest || typeof manifest.blueprintDigest !== "string") { logger.error( - "v3 snapshot manifest has missing or invalid blueprintDigest. Refusing to restore.", + "Snapshot manifest has empty or invalid blueprintDigest. Refusing to restore.", + ); ); return false; } From 31375466d586a4260e8129976ada25a44c5f54de Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 25 Mar 2026 12:59:44 -0700 Subject: [PATCH 6/6] fix(security): add stripCredentials to prepareSandboxState copy and fix duplicate closing paren --- nemoclaw/src/commands/migration-state.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index aa1b4f777..09deb2f69 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -574,7 +574,7 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s const preparedStateDir = path.join(snapshotDir, "sandbox-bundle", "openclaw"); rmSync(preparedStateDir, { recursive: true, force: true }); mkdirSync(path.dirname(preparedStateDir), { recursive: true }); - copyDirectory(path.join(snapshotDir, "openclaw"), preparedStateDir); + copyDirectory(path.join(snapshotDir, "openclaw"), preparedStateDir, { stripCredentials: true }); const configSourcePath = resolveConfigSourcePath(manifest, snapshotDir); const config = existsSync(configSourcePath) ? (loadConfigDocument(configSourcePath) ?? {}) : {}; @@ -791,10 +791,7 @@ export function restoreSnapshotToHost( // verification. if ("blueprintDigest" in manifest) { if (!manifest.blueprintDigest || typeof manifest.blueprintDigest !== "string") { - logger.error( - "Snapshot manifest has empty or invalid blueprintDigest. Refusing to restore.", - ); - ); + logger.error("Snapshot manifest has empty or invalid blueprintDigest. Refusing to restore."); return false; } const currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null;