Skip to content
Merged
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
145 changes: 141 additions & 4 deletions nemoclaw/src/commands/migration-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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);

Expand Down
122 changes: 97 additions & 25 deletions nemoclaw/src/commands/migration-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
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<string, unknown>;
delete config["gateway"];
const sanitized = stripCredentials(config) as Record<string, unknown>;
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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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[] = [];
Expand All @@ -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);
Expand Down Expand Up @@ -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. " +
Expand Down