Config: add secret ref schema and redaction foundations

This commit is contained in:
joshavant
2026-02-21 10:55:17 -08:00
parent 2e8e357bf7
commit 7103ba3758
12 changed files with 245 additions and 8 deletions

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "./validation.js";
describe("config secret refs schema", () => {
it("accepts top-level secrets sources and model apiKey refs", () => {
const result = validateConfigObjectRaw({
secrets: {
sources: {
env: { type: "env" },
file: { type: "sops", path: "~/.openclaw/secrets.enc.json", timeoutMs: 10_000 },
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
expect(result.ok).toBe(true);
});
it("accepts googlechat serviceAccount refs", () => {
const result = validateConfigObjectRaw({
channels: {
googlechat: {
serviceAccountRef: { source: "file", id: "/channels/googlechat/serviceAccount" },
},
},
});
expect(result.ok).toBe(true);
});
it("rejects invalid secret ref id", () => {
const result = validateConfigObjectRaw({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "bad id with spaces" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.issues.some((issue) => issue.path.includes("models.providers.openai.apiKey")),
).toBe(true);
}
});
});

View File

@@ -74,7 +74,6 @@ describe("redactConfigSnapshot", () => {
},
shortSecret: { token: "short" },
});
const result = redactConfigSnapshot(snapshot);
const cfg = result.config as typeof snapshot.config;
@@ -91,6 +90,45 @@ describe("redactConfigSnapshot", () => {
expect(cfg.shortSecret.token).toBe(REDACTED_SENTINEL);
});
it("redacts googlechat serviceAccount object payloads", () => {
const snapshot = makeSnapshot({
channels: {
googlechat: {
serviceAccount: {
type: "service_account",
client_email: "bot@example.iam.gserviceaccount.com",
private_key: "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----",
},
},
},
});
const result = redactConfigSnapshot(snapshot);
const channels = result.config.channels as Record<string, Record<string, unknown>>;
expect(channels.googlechat.serviceAccount).toBe(REDACTED_SENTINEL);
});
it("redacts object-valued apiKey refs in model providers", () => {
const snapshot = makeSnapshot({
models: {
providers: {
openai: {
apiKey: { source: "env", id: "OPENAI_API_KEY" },
baseUrl: "https://api.openai.com",
},
},
},
});
const result = redactConfigSnapshot(snapshot);
const models = result.config.models as Record<string, Record<string, Record<string, unknown>>>;
expect(models.providers.openai.apiKey).toEqual({
source: REDACTED_SENTINEL,
id: REDACTED_SENTINEL,
});
expect(models.providers.openai.baseUrl).toBe("https://api.openai.com");
});
it("preserves non-sensitive fields", () => {
const snapshot = makeSnapshot({
ui: { seamColor: "#0088cc" },

View File

@@ -17,6 +17,31 @@ function isEnvVarPlaceholder(value: string): boolean {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
function isWholeObjectSensitivePath(path: string): boolean {
const lowered = path.toLowerCase();
return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref");
}
function collectSensitiveStrings(value: unknown, values: string[]): void {
if (typeof value === "string") {
if (!isEnvVarPlaceholder(value)) {
values.push(value);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectSensitiveStrings(item, values);
}
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value as Record<string, unknown>)) {
collectSensitiveStrings(item, values);
}
}
}
function isExtensionPath(path: string): boolean {
return (
path === "plugins" ||
@@ -159,7 +184,19 @@ function redactObjectWithLookup(
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (typeof value === "object" && value !== null) {
result[key] = redactObjectWithLookup(value, lookup, candidate, values, hints);
if (hints[candidate]?.sensitive === true && !Array.isArray(value)) {
collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL;
} else {
result[key] = redactObjectWithLookup(value, lookup, candidate, values, hints);
}
} else if (
hints[candidate]?.sensitive === true &&
value !== undefined &&
value !== null
) {
// Keep primitives at explicitly-sensitive paths fully redacted.
result[key] = REDACTED_SENTINEL;
}
break;
}
@@ -228,6 +265,16 @@ function redactObjectGuessing(
) {
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (
!isExplicitlyNonSensitivePath(hints, [dotPath, wildcardPath]) &&
isSensitivePath(dotPath) &&
isWholeObjectSensitivePath(dotPath) &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL;
} else if (typeof value === "object" && value !== null) {
result[key] = redactObjectGuessing(value, dotPath, values, hints);
} else {

View File

@@ -109,6 +109,7 @@ describe("mapSensitivePaths", () => {
expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true);
expect(hints["agents.list[].memorySearch.remote.apiKey"]?.sensitive).toBe(true);
expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true);
expect(hints["channels.googlechat.serviceAccount"]?.sensitive).toBe(true);
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true);
});

View File

@@ -107,7 +107,13 @@ const NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES = SENSITIVE_KEY_WHITELIST_SUFF
suffix.toLowerCase(),
);
const SENSITIVE_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i];
const SENSITIVE_PATTERNS = [
/token$/i,
/password/i,
/secret/i,
/api.?key/i,
/serviceaccount(?:ref)?$/i,
];
function isWhitelistedSensitivePath(path: string): boolean {
const lowerPath = path.toLowerCase();

View File

@@ -5,6 +5,7 @@ import type {
ReplyToMode,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
import type { SecretRef } from "./types.secrets.js";
export type GoogleChatDmConfig = {
/** If false, ignore all incoming Google Chat DMs. Default: true. */
@@ -58,8 +59,10 @@ export type GoogleChatAccountConfig = {
defaultTo?: string;
/** Per-space configuration keyed by space id or name. */
groups?: Record<string, GoogleChatGroupConfig>;
/** Service account JSON (inline string or object). */
serviceAccount?: string | Record<string, unknown>;
/** Service account JSON (inline string, object, or secret reference). */
serviceAccount?: string | Record<string, unknown> | SecretRef;
/** Explicit secret reference for service account JSON. */
serviceAccountRef?: SecretRef;
/** Service account JSON file path. */
serviceAccountFile?: string;
/** Webhook audience type (app-url or project-number). */

View File

@@ -22,6 +22,7 @@ import type {
import type { ModelsConfig } from "./types.models.js";
import type { NodeHostConfig } from "./types.node-host.js";
import type { PluginsConfig } from "./types.plugins.js";
import type { SecretsConfig } from "./types.secrets.js";
import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js";
@@ -75,6 +76,7 @@ export type OpenClawConfig = {
avatar?: string;
};
};
secrets?: SecretsConfig;
skills?: SkillsConfig;
plugins?: PluginsConfig;
models?: ModelsConfig;

View File

@@ -0,0 +1,31 @@
export type SecretRefSource = "env" | "file";
/**
* Stable identifier for a secret in a configured source.
* Examples:
* - env source: "OPENAI_API_KEY"
* - file source: "/providers/openai/api_key" (JSON pointer)
*/
export type SecretRef = {
source: SecretRefSource;
id: string;
};
export type SecretInput = string | SecretRef;
export type EnvSecretSourceConfig = {
type?: "env";
};
export type SopsSecretSourceConfig = {
type: "sops";
path: string;
timeoutMs?: number;
};
export type SecretsConfig = {
sources?: {
env?: EnvSecretSourceConfig;
file?: SopsSecretSourceConfig;
};
};

View File

@@ -22,6 +22,7 @@ export * from "./types.msteams.js";
export * from "./types.plugins.js";
export * from "./types.queue.js";
export * from "./types.sandbox.js";
export * from "./types.secrets.js";
export * from "./types.signal.js";
export * from "./types.skills.js";
export * from "./types.slack.js";

View File

@@ -3,6 +3,49 @@ import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js";
import { sensitive } from "./zod-schema.sensitive.js";
const SECRET_REF_ID_PATTERN = /^[A-Za-z0-9_./:=-](?:[A-Za-z0-9_./:=~-]{0,127})$/;
export const SecretRefSchema = z
.object({
source: z.enum(["env", "file"]),
id: z
.string()
.regex(
SECRET_REF_ID_PATTERN,
"Secret reference id must match /^[A-Za-z0-9_./:=-](?:[A-Za-z0-9_./:=~-]{0,127})$/",
),
})
.strict();
export const SecretInputSchema = z.union([z.string(), SecretRefSchema]);
const SecretsEnvSourceSchema = z
.object({
type: z.literal("env").optional(),
})
.strict();
const SecretsFileSourceSchema = z
.object({
type: z.literal("sops"),
path: z.string().min(1),
timeoutMs: z.number().int().positive().max(120000).optional(),
})
.strict();
export const SecretsConfigSchema = z
.object({
sources: z
.object({
env: SecretsEnvSourceSchema.optional(),
file: SecretsFileSourceSchema.optional(),
})
.strict()
.optional(),
})
.strict()
.optional();
export const ModelApiSchema = z.union([
z.literal("openai-completions"),
z.literal("openai-responses"),
@@ -58,7 +101,7 @@ export const ModelDefinitionSchema = z
export const ModelProviderSchema = z
.object({
baseUrl: z.string().min(1),
apiKey: z.string().optional().register(sensitive),
apiKey: SecretInputSchema.optional().register(sensitive),
auth: z
.union([z.literal("api-key"), z.literal("aws-sdk"), z.literal("oauth"), z.literal("token")])
.optional(),

View File

@@ -25,6 +25,7 @@ import {
MarkdownConfigSchema,
MSTeamsReplyStyleSchema,
ProviderCommandsSchema,
SecretRefSchema,
ReplyToModeSchema,
RetryConfigSchema,
TtsConfigSchema,
@@ -519,7 +520,11 @@ export const GoogleChatAccountSchema = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(),
defaultTo: z.string().optional(),
serviceAccount: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
serviceAccount: z
.union([z.string(), z.record(z.string(), z.unknown()), SecretRefSchema])
.optional()
.register(sensitive),
serviceAccountRef: SecretRefSchema.optional().register(sensitive),
serviceAccountFile: z.string().optional(),
audienceType: z.enum(["app-url", "project-number"]).optional(),
audience: z.string().optional(),

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { ApprovalsSchema } from "./zod-schema.approvals.js";
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HexColorSchema, ModelsConfigSchema, SecretsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
import { InstallRecordShape } from "./zod-schema.installs.js";
import { ChannelsSchema } from "./zod-schema.providers.js";
@@ -263,6 +263,7 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
secrets: SecretsConfigSchema,
auth: z
.object({
profiles: z