From c3a4251a602f239222703645026fb2e8d55b28ec Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Sat, 21 Feb 2026 10:55:17 -0800 Subject: [PATCH] Config: add secret ref schema and redaction foundations --- src/config/config.secrets-schema.test.ts | 59 ++++++++++++++++++++++++ src/config/redact-snapshot.test.ts | 40 +++++++++++++++- src/config/redact-snapshot.ts | 57 ++++++++++++++++++++++- src/config/schema.hints.test.ts | 1 + src/config/schema.hints.ts | 8 +++- src/config/types.googlechat.ts | 7 ++- src/config/types.openclaw.ts | 2 + src/config/types.secrets.ts | 31 +++++++++++++ src/config/types.ts | 1 + src/config/zod-schema.core.ts | 45 +++++++++++++++++- src/config/zod-schema.providers-core.ts | 7 ++- src/config/zod-schema.ts | 3 +- 12 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 src/config/config.secrets-schema.test.ts create mode 100644 src/config/types.secrets.ts diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts new file mode 100644 index 00000000000..bd5762d8fe0 --- /dev/null +++ b/src/config/config.secrets-schema.test.ts @@ -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); + } + }); +}); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index ee3dc62b421..9881bb915fd 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -95,7 +95,6 @@ describe("redactConfigSnapshot", () => { }, shortSecret: { token: "short" }, }); - const result = redactConfigSnapshot(snapshot); const cfg = result.config as typeof snapshot.config; @@ -112,6 +111,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>; + 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>>; + 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" }, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 91b2e76f990..38559e764b8 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -17,6 +17,39 @@ 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)) { + collectSensitiveStrings(item, values); + } + } +} + +function isExtensionPath(path: string): boolean { + return ( + path === "plugins" || + path.startsWith("plugins.") || + path === "channels" || + path.startsWith("channels.") + ); +} function isExplicitlyNonSensitivePath(hints: ConfigUiHints | undefined, paths: string[]): boolean { if (!hints) { return false; @@ -149,7 +182,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; } @@ -221,6 +266,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 { diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index dec154d0485..41ac8b1aa5d 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -133,6 +133,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); }); diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 06fa93efea5..05b31d695b3 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -109,7 +109,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(); diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 070bf379b3b..091c4f0f271 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -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. */ @@ -63,8 +64,10 @@ export type GoogleChatAccountConfig = { defaultTo?: string; /** Per-space configuration keyed by space id or name. */ groups?: Record; - /** Service account JSON (inline string or object). */ - serviceAccount?: string | Record; + /** Service account JSON (inline string, object, or secret reference). */ + serviceAccount?: string | Record | 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). */ diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index f6e94c45ff7..f3374083de8 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -23,6 +23,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"; @@ -88,6 +89,7 @@ export type OpenClawConfig = { avatar?: string; }; }; + secrets?: SecretsConfig; skills?: SkillsConfig; plugins?: PluginsConfig; models?: ModelsConfig; diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts new file mode 100644 index 00000000000..49662d35384 --- /dev/null +++ b/src/config/types.secrets.ts @@ -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; + }; +}; diff --git a/src/config/types.ts b/src/config/types.ts index fa2fb7268a4..50ee48c9b54 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -23,6 +23,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"; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index d99ebe3b907..052f3a47a58 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -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(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 2d90ee56c5b..8105d2fc77f 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -25,6 +25,7 @@ import { MarkdownConfigSchema, MSTeamsReplyStyleSchema, ProviderCommandsSchema, + SecretRefSchema, ReplyToModeSchema, RetryConfigSchema, TtsConfigSchema, @@ -554,7 +555,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(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 30e8c2e8031..3bf8dceee67 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -4,7 +4,7 @@ import { parseDurationMs } from "../cli/parse-duration.js"; 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"; @@ -289,6 +289,7 @@ export const OpenClawSchema = z }) .strict() .optional(), + secrets: SecretsConfigSchema, auth: z .object({ profiles: z