diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index d8e2c307f..70575be66 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -577,12 +577,17 @@ describe("commands/migration-state", () => { expect(configKeys.length).toBeGreaterThan(0); }); - it("strips gateway key from sandbox openclaw.json", () => { + it("strips gateway key and credential fields 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" } } }), + JSON.stringify({ + version: 1, + gateway: { auth: { token: "secret123" } }, + nvidia: { apiKey: "nvapi-test-key" }, + agents: { defaults: { model: { primary: "test-model" } } }, + }), ); const hostState: HostOpenClawState = { @@ -614,8 +619,65 @@ describe("commands/migration-state", () => { return; } const sandboxConfig = JSON.parse(sandboxConfigEntry.content); + // gateway key should be removed entirely expect(sandboxConfig).not.toHaveProperty("gateway"); - expect(sandboxConfig).toHaveProperty("version", 1); + // credential fields should be stripped + expect(sandboxConfig.nvidia.apiKey).toBe("[STRIPPED_BY_MIGRATION]"); + // non-credential fields should be preserved + expect(sandboxConfig.version).toBe(1); + expect(sandboxConfig.agents.defaults.model.primary).toBe("test-model"); + }); + + it("strips pattern-matched credential fields (accessToken, privateKey, etc.)", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile( + "/home/user/.openclaw/openclaw.json", + JSON.stringify({ + version: 1, + provider: { + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + privateKey: "test-private-key", + clientSecret: "test-client-secret", + displayName: "should-be-preserved", + }, + }), + ); + + 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; + } + + 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.provider.accessToken).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.refreshToken).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.privateKey).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.clientSecret).toBe("[STRIPPED_BY_MIGRATION]"); + expect(sandboxConfig.provider.displayName).toBe("should-be-preserved"); }); it("records blueprintDigest when blueprintPath is provided", () => { @@ -680,6 +742,82 @@ describe("commands/migration-state", () => { } expect(bundle.manifest.blueprintDigest).toBeUndefined(); }); + + it("fails when blueprintPath is provided but file is missing", () => { + 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, + }; + + // /test/nonexistent.yaml does not exist in store + const bundle = createSnapshotBundle(hostState, logger, { + persist: false, + blueprintPath: "/test/nonexistent.yaml", + }); + expect(bundle).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + + it("sanitizes credentials in the snapshot directory itself (not just sandbox-bundle)", () => { + const logger = makeLogger(); + addDir("/home/user/.openclaw"); + addFile( + "/home/user/.openclaw/openclaw.json", + JSON.stringify({ + version: 1, + gateway: { auth: { token: "secret123" } }, + nvidia: { apiKey: "nvapi-test-key" }, + }), + ); + + 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; + } + + // Check the snapshot-level openclaw.json (not sandbox-bundle) + const snapshotConfigEntry = store.get(bundle.snapshotDir + "/openclaw/openclaw.json"); + if (!snapshotConfigEntry?.content) { + expect.unreachable("snapshot config entry should exist with content"); + return; + } + const snapshotConfig = JSON.parse(snapshotConfigEntry.content); + expect(snapshotConfig).not.toHaveProperty("gateway"); + expect(snapshotConfig.nvidia.apiKey).toBe("[STRIPPED_BY_MIGRATION]"); + expect(snapshotConfig.version).toBe(1); + }); }); // ------------------------------------------------------------------------- @@ -1053,7 +1191,6 @@ describe("commands/migration-state", () => { 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); diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index 09deb2f69..4a4345e66 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -476,14 +476,9 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp }; } -function computeFileDigest(filePath: string): string | null { - try { - if (!existsSync(filePath)) return null; - return createHash("sha256").update(readFileSync(filePath)).digest("hex"); - } catch { - return null; - } -} +// --------------------------------------------------------------------------- +// Credential sanitization +// --------------------------------------------------------------------------- /** * Basenames that MUST NOT be copied into snapshot bundles. @@ -492,6 +487,77 @@ function computeFileDigest(filePath: string): string | null { */ const CREDENTIAL_SENSITIVE_BASENAMES = new Set(["auth-profiles.json"]); +/** + * Credential field names that MUST be stripped from config files + * before they enter the sandbox. Credentials should be injected + * at runtime via OpenShell's provider credential mechanism. + */ +const CREDENTIAL_FIELDS = new Set([ + "apiKey", + "api_key", + "token", + "secret", + "password", + "resolvedKey", +]); + +/** + * Pattern-based detection for credential field names not covered by the + * explicit set above. Matches common suffixes like accessToken, privateKey, + * clientSecret, etc. + */ +const CREDENTIAL_FIELD_PATTERN = + /(?:access|refresh|client|bearer|auth|api|private|public|signing|session)(?:Token|Key|Secret|Password)$/; + +function isCredentialField(key: string): boolean { + return CREDENTIAL_FIELDS.has(key) || CREDENTIAL_FIELD_PATTERN.test(key); +} + +/** + * Recursively strip credential fields from a JSON-like object. + * Returns a new object with sensitive values replaced by a placeholder. + */ +function stripCredentials(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(stripCredentials); + + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (isCredentialField(key)) { + result[key] = "[STRIPPED_BY_MIGRATION]"; + } else { + result[key] = stripCredentials(value); + } + } + return result; +} + +/** + * Strip credential fields from openclaw.json and remove the gateway + * config section (contains auth tokens — regenerated by sandbox entrypoint). + */ +function sanitizeConfigFile(configPath: string): void { + if (!existsSync(configPath)) return; + const raw = readFileSync(configPath, "utf-8"); + const parsed: unknown = JSON5.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; + const config = parsed as Record; + delete config["gateway"]; + const sanitized = stripCredentials(config) as Record; + writeFileSync(configPath, JSON.stringify(sanitized, null, 2)); + chmodSync(configPath, 0o600); +} + +function computeFileDigest(filePath: string): string { + if (!existsSync(filePath)) { + throw new Error(`Blueprint file not found: ${filePath}`); + } + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +// --------------------------------------------------------------------------- + function copyDirectory( sourcePath: string, destinationPath: string, @@ -591,6 +657,13 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s const configPath = path.join(preparedStateDir, "openclaw.json"); writeFileSync(configPath, JSON.stringify(config, null, 2)); chmodSync(configPath, 0o600); + + // SECURITY: Strip all credentials from the bundle before it enters the sandbox. + // Credentials must be injected at runtime via OpenShell's provider credential + // mechanism, not baked into the sandbox filesystem where a compromised agent + // can read them. + sanitizeConfigFile(configPath); + return preparedStateDir; } @@ -616,13 +689,14 @@ export function createSnapshotBundle( mkdirSync(parentDir, { recursive: true }); const snapshotStateDir = path.join(parentDir, "openclaw"); copyDirectory(hostState.stateDir, snapshotStateDir, { stripCredentials: true }); + sanitizeConfigFile(path.join(snapshotStateDir, "openclaw.json")); if (hostState.configPath && hostState.hasExternalConfig) { const configSnapshotDir = path.join(parentDir, "config"); mkdirSync(configSnapshotDir, { recursive: true }); const configSnapshotPath = path.join(configSnapshotDir, "openclaw.json"); copyFileSync(hostState.configPath, configSnapshotPath); - chmodSync(configSnapshotPath, 0o600); + sanitizeConfigFile(configSnapshotPath); } const externalRoots: MigrationExternalRoot[] = []; @@ -647,15 +721,8 @@ export function createSnapshotBundle( warnings: hostState.warnings, }; - 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; + if (options.blueprintPath !== undefined) { + manifest.blueprintDigest = computeFileDigest(options.blueprintPath); } writeSnapshotManifest(parentDir, manifest); @@ -783,18 +850,23 @@ export function restoreSnapshotToHost( } } - // SECURITY: Validate blueprint digest. - // 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. + // SECURITY: Validate blueprint digest when present in manifest if ("blueprintDigest" in manifest) { if (!manifest.blueprintDigest || typeof manifest.blueprintDigest !== "string") { logger.error("Snapshot manifest has empty or invalid blueprintDigest. Refusing to restore."); return false; } - const currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null; + let currentDigest: string | null = null; + try { + currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null; + } catch (err: unknown) { + logger.error( + `Failed to read blueprint for digest verification: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return false; + } if (!currentDigest) { logger.error( "Snapshot contains a blueprintDigest but no blueprint is available for verification. " +