Config: add secret ref schema and redaction foundations
This commit is contained in:
59
src/config/config.secrets-schema.test.ts
Normal file
59
src/config/config.secrets-schema.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
src/config/types.secrets.ts
Normal file
31
src/config/types.secrets.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user