diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 2d58f31fdac..5424840bf4c 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -1070,12 +1070,13 @@ Hardening tips: OpenClaw loads workspace-local `.env` files for agents and tools, but never lets those files silently override gateway runtime controls. +- Provider credential environment variables are blocked from untrusted workspace `.env` files. Examples include `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `FIRECRAWL_API_KEY`, and provider auth keys declared by installed trusted plugins. Put provider credentials in the Gateway process environment, `~/.openclaw/.env` (`$OPENCLAW_STATE_DIR/.env`), the config `env` block, or optional login-shell import. - Any key that starts with `OPENCLAW_*` is blocked from untrusted workspace `.env` files. - Channel endpoint settings for Matrix, Mattermost, IRC, and Synology Chat are also blocked from workspace `.env` overrides, so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. Endpoint env keys (such as `MATRIX_HOMESERVER`, `MATTERMOST_URL`, `IRC_HOST`, `SYNOLOGY_CHAT_INCOMING_URL`) must come from the gateway process environment or `env.shellEnv`, not from a workspace-loaded `.env`. - The block is fail-closed: a new runtime-control variable added in a future release cannot be inherited from a checked-in or attacker-supplied `.env`; the key is ignored and the gateway keeps its own value. -- Trusted process/OS environment variables (the gateway's own shell, launchd/systemd unit, app bundle) still apply - this only constrains `.env` file loading. +- Trusted process/OS environment variables, global runtime dotenv, config `env`, and enabled login-shell import still apply - this only constrains workspace `.env` file loading. -Why: workspace `.env` files frequently live next to agent code, get committed by accident, or get written by tools. Blocking the whole `OPENCLAW_*` prefix means adding a new `OPENCLAW_*` flag later can never regress into silent inheritance from workspace state. +Why: workspace `.env` files frequently live next to agent code, get committed by accident, or get written by tools. Blocking provider credentials prevents a cloned workspace from substituting attacker-controlled provider accounts. Blocking the whole `OPENCLAW_*` prefix means adding a new `OPENCLAW_*` flag later can never regress into silent inheritance from workspace state. ### Logs and transcripts (redaction and retention) diff --git a/docs/help/environment.md b/docs/help/environment.md index 2158f4e7d6b..b1666ac136e 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -8,12 +8,13 @@ title: "Environment variables" --- OpenClaw pulls environment variables from multiple sources. The rule is **never override existing values**. +Workspace `.env` files are a lower-trust source: OpenClaw ignores provider credentials and protected runtime controls from workspace `.env` before applying precedence. ## Precedence (highest → lowest) 1. **Process environment** (what the Gateway process already has from the parent shell/daemon). -2. **`.env` in the current working directory** (dotenv default; does not override). -3. **Global `.env`** at `~/.openclaw/.env` (aka `$OPENCLAW_STATE_DIR/.env`; does not override). +2. **`.env` in the current working directory** (dotenv default; does not override; provider credentials and protected runtime controls are ignored). +3. **Global `.env`** at `~/.openclaw/.env` (aka `$OPENCLAW_STATE_DIR/.env`; recommended for provider API keys; does not override). 4. **Config `env` block** in `~/.openclaw/openclaw.json` (applied only if missing). 5. **Optional login-shell import** (`env.shellEnv.enabled` or `OPENCLAW_LOAD_SHELL_ENV=1`), applied only for missing expected keys. @@ -21,6 +22,21 @@ On Ubuntu fresh installs that use the default state dir, OpenClaw also treats `~ If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled. +## Provider credentials and workspace `.env` + +Do not keep provider API keys only in a workspace `.env`. OpenClaw ignores provider credential environment variables from workspace `.env` files, including common keys such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, and `FIRECRAWL_API_KEY`. + +Use one of these trusted sources for provider credentials: + +- The Gateway process environment, such as a shell, launchd/systemd unit, container secret, or CI secret. +- The global runtime dotenv file at `~/.openclaw/.env` or `$OPENCLAW_STATE_DIR/.env`. +- The config `env` block in `~/.openclaw/openclaw.json`. +- Optional login-shell import when `env.shellEnv.enabled` or `OPENCLAW_LOAD_SHELL_ENV=1` is enabled. + +If you previously stored provider keys only in a workspace `.env`, move them to one of the trusted sources above. Workspace `.env` can still provide ordinary project variables that are not credentials, endpoint redirects, host overrides, or `OPENCLAW_*` runtime controls. + +See [Workspace `.env` files](/gateway/security#workspace-env-files) for the security rationale. + ## Config `env` block Two equivalent ways to set inline env vars (both are non-overriding): diff --git a/docs/help/faq.md b/docs/help/faq.md index cef2664c271..558d797a6c9 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1085,6 +1085,9 @@ lives on the [First-run FAQ](/help/faq-first-run). - a global fallback `.env` from `~/.openclaw/.env` (aka `$OPENCLAW_STATE_DIR/.env`) Neither `.env` file overrides existing env vars. + Provider credential variables are an exception for workspace `.env`: keys such as + `GEMINI_API_KEY`, `XAI_API_KEY`, or `MISTRAL_API_KEY` are ignored from workspace + `.env` and should live in the process environment, `~/.openclaw/.env`, or config `env`. You can also define inline env vars in config (applied only if missing from the process env): diff --git a/src/docs/environment-docs.test.ts b/src/docs/environment-docs.test.ts new file mode 100644 index 00000000000..679e7a4ae5f --- /dev/null +++ b/src/docs/environment-docs.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +async function readDoc(relativePath: string): Promise { + return await fs.readFile(path.join(process.cwd(), relativePath), "utf8"); +} + +const providerCredentialExamples = [ + "GEMINI_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "BRAVE_API_KEY", +] as const; + +describe("environment docs", () => { + it("documents the trusted sources for provider credentials", async () => { + const markdown = await readDoc("docs/help/environment.md"); + + expect(markdown).toContain("Provider credentials and workspace `.env`"); + expect(markdown).toContain( + "OpenClaw ignores provider credential environment variables from workspace `.env` files", + ); + expect(markdown).toContain("~/.openclaw/.env"); + expect(markdown).toContain("$OPENCLAW_STATE_DIR/.env"); + expect(markdown).toContain("The config `env` block"); + expect(markdown).toContain("OPENCLAW_LOAD_SHELL_ENV=1"); + + for (const key of providerCredentialExamples) { + expect(markdown).toContain(key); + } + }); + + it("keeps the security guide aligned with the workspace dotenv credential boundary", async () => { + const markdown = await readDoc("docs/gateway/security/index.md"); + + expect(markdown).toContain( + "Provider credential environment variables are blocked from untrusted workspace `.env` files", + ); + expect(markdown).toContain("provider auth keys declared by installed trusted plugins"); + expect(markdown).toContain("~/.openclaw/.env"); + expect(markdown).toContain("$OPENCLAW_STATE_DIR/.env"); + + for (const key of providerCredentialExamples) { + expect(markdown).toContain(key); + } + }); +}); diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 9e2744f0864..a34e67e970c 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -3,6 +3,14 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { loadCliDotEnv } from "../cli/dotenv.js"; +import { + clearCurrentPluginMetadataSnapshot, + setCurrentPluginMetadataSnapshot, +} from "../plugins/current-plugin-metadata-snapshot.js"; +import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.js"; import { loadDotEnv, loadWorkspaceDotEnvFile } from "./dotenv.js"; const loggerMocks = vi.hoisted(() => ({ @@ -108,6 +116,54 @@ type DotEnvFixture = { stateDir: string; }; +function emptyOwnerMaps(): PluginMetadataSnapshot["owners"] { + return { + channels: new Map(), + channelConfigs: new Map(), + providers: new Map(), + modelCatalogProviders: new Map(), + cliBackends: new Map(), + setupProviders: new Map(), + commandAliases: new Map(), + contracts: new Map(), + }; +} + +function createManifestBackedProviderSnapshot( + plugin: PluginManifestRecord, +): PluginMetadataSnapshot { + const policyHash = resolveInstalledPluginIndexPolicyHash({}); + return { + policyHash, + index: { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash, + generatedAtMs: 0, + installRecords: {}, + plugins: [], + diagnostics: [], + }, + registryDiagnostics: [], + manifestRegistry: { plugins: [plugin], diagnostics: [] }, + plugins: [plugin], + diagnostics: [], + byPluginId: new Map([[plugin.id, plugin]]), + normalizePluginId: (pluginId: string) => pluginId, + owners: emptyOwnerMaps(), + metrics: { + registrySnapshotMs: 0, + manifestRegistryMs: 0, + ownerMapsMs: 0, + totalMs: 0, + indexPluginCount: 0, + manifestPluginCount: 1, + }, + }; +} + async function withDotEnvFixture(run: (fixture: DotEnvFixture) => Promise) { const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-")); const cwdDir = path.join(base, "cwd"); @@ -749,6 +805,78 @@ describe("loadCliDotEnv", () => { }); describe("workspace .env blocklist completeness", () => { + it("keeps trusted global dotenv for global plugin provider auth vars", async () => { + await withIsolatedEnvAndCwd(async () => { + await withDotEnvFixture(async ({ cwdDir, stateDir }) => { + const plugin: PluginManifestRecord = { + id: "runtime-cloud", + channels: [], + providers: ["runtime-cloud"], + cliBackends: [], + skills: [], + hooks: [], + origin: "global", + rootDir: "/plugins/runtime-cloud", + source: "/plugins/runtime-cloud/index.js", + manifestPath: "/plugins/runtime-cloud/openclaw.plugin.json", + providerAuthEnvVars: { + "runtime-cloud": ["RUNTIME_CLOUD_API_KEY"], + }, + }; + await writeEnvFile( + path.join(cwdDir, ".env"), + "RUNTIME_CLOUD_API_KEY=workspace-plugin-key\n", + ); + await writeEnvFile( + path.join(stateDir, ".env"), + "RUNTIME_CLOUD_API_KEY=global-plugin-key\n", + ); + + delete process.env.RUNTIME_CLOUD_API_KEY; + vi.spyOn(process, "cwd").mockReturnValue(cwdDir); + setCurrentPluginMetadataSnapshot(createManifestBackedProviderSnapshot(plugin), { + config: {}, + env: process.env, + }); + + try { + loadDotEnv({ quiet: true }); + + expect(process.env.RUNTIME_CLOUD_API_KEY).toBe("global-plugin-key"); + } finally { + clearCurrentPluginMetadataSnapshot(); + } + }); + }); + }); + + it("keeps registered provider auth vars from trusted global dotenv", async () => { + await withIsolatedEnvAndCwd(async () => { + await withDotEnvFixture(async ({ cwdDir, stateDir }) => { + const providerAuthKeys = listKnownProviderAuthEnvVarNames().toSorted(); + await writeEnvFile( + path.join(cwdDir, ".env"), + `${providerAuthKeys.map((key) => `${key}=workspace-${key}`).join("\n")}\n`, + ); + await writeEnvFile( + path.join(stateDir, ".env"), + `${providerAuthKeys.map((key) => `${key}=global-${key}`).join("\n")}\n`, + ); + + clearEnv(providerAuthKeys); + vi.spyOn(process, "cwd").mockReturnValue(cwdDir); + + loadDotEnv({ quiet: true }); + + for (const key of providerAuthKeys) { + expect(process.env[key], `${key} should come from trusted global .env`).toBe( + `global-${key}`, + ); + } + }); + }); + }); + it("blocks runtime-control variables from workspace .env", async () => { await withIsolatedEnvAndCwd(async () => { await withDotEnvFixture(async ({ cwdDir }) => { diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index 56f612efc2f..bc5baae6559 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import dotenv from "dotenv"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.js"; import { resolveConfigDir } from "../utils.js"; import { resolveRequiredHomeDir } from "./home-dir.js"; import { @@ -13,10 +14,95 @@ import { const logger = createSubsystemLogger("infra:dotenv"); -const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ - "ALL_PROXY", +const BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS = [ + "AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", + "ARCEEAI_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_SPEECH_API_KEY", + "AZURE_SPEECH_KEY", + "AZURE_SPEECH_REGION", + "BRAVE_API_KEY", + "BYTEPLUS_API_KEY", + "BYTEPLUS_SEED_SPEECH_API_KEY", + "CEREBRAS_API_KEY", + "CHUTES_API_KEY", + "CHUTES_OAUTH_TOKEN", + "CLOUDFLARE_AI_GATEWAY_API_KEY", + "COMFY_API_KEY", + "COMFY_CLOUD_API_KEY", + "COPILOT_GITHUB_TOKEN", + "DASHSCOPE_API_KEY", + "DEEPGRAM_API_KEY", + "DEEPINFRA_API_KEY", + "DEEPSEEK_API_KEY", + "ELEVENLABS_API_KEY", + "EXA_API_KEY", + "FAL_API_KEY", + "FAL_KEY", + "FIRECRAWL_API_KEY", + "FIREWORKS_API_KEY", + "GEMINI_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "GOOGLE_API_KEY", + "GOOGLE_CLOUD_API_KEY", + "GRADIUM_API_KEY", + "GROQ_API_KEY", + "HF_TOKEN", + "HUGGINGFACE_HUB_TOKEN", + "INWORLD_API_KEY", + "KILOCODE_API_KEY", + "KIMICODE_API_KEY", + "KIMI_API_KEY", + "LITELLM_API_KEY", + "LM_API_TOKEN", + "MINIMAX_API_KEY", + "MINIMAX_CODE_PLAN_KEY", + "MINIMAX_CODING_API_KEY", + "MINIMAX_OAUTH_TOKEN", + "MISTRAL_API_KEY", + "MODELSTUDIO_API_KEY", + "MOONSHOT_API_KEY", + "NVIDIA_API_KEY", + "OLLAMA_API_KEY", + "OPENAI_API_KEY", + "OPENCODE_API_KEY", + "OPENCODE_ZEN_API_KEY", + "OPENROUTER_API_KEY", + "PERPLEXITY_API_KEY", + "QIANFAN_API_KEY", + "QWEN_API_KEY", + "RUNWAY_API_KEY", + "RUNWAYML_API_SECRET", + "SENSEAUDIO_API_KEY", + "SGLANG_API_KEY", + "SPEECH_KEY", + "SPEECH_REGION", + "STEPFUN_API_KEY", + "SYNTHETIC_API_KEY", + "TAVILY_API_KEY", + "TOGETHER_API_KEY", + "TOKENHUB_API_KEY", + "VENICE_API_KEY", + "VLLM_API_KEY", + "VOLCANO_ENGINE_API_KEY", + "VOLCENGINE_TTS_API_KEY", + "VOLCENGINE_TTS_APPID", + "VOLCENGINE_TTS_TOKEN", + "VOYAGE_API_KEY", + "VYDRA_API_KEY", + "XAI_API_KEY", + "XIAOMI_API_KEY", + "XI_API_KEY", + "ZAI_API_KEY", + "Z_AI_API_KEY", +] as const; + +const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ + ...BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS, + "ALL_PROXY", "BROWSER_EXECUTABLE_PATH", "CLAWHUB_AUTH_TOKEN", "CLAWHUB_CONFIG_PATH", @@ -36,7 +122,6 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "NODE_TLS_REJECT_UNAUTHORIZED", "NO_PROXY", "NPM_EXECPATH", - "OPENAI_API_KEY", "OPENAI_API_KEYS", "OPENCLAW_AGENT_DIR", "OPENCLAW_ALLOW_PLUGIN_INSTALL_OVERRIDES", @@ -120,13 +205,30 @@ function shouldBlockRuntimeDotEnvKey(key: string): boolean { return false; } -function shouldBlockWorkspaceDotEnvKey(key: string): boolean { +function buildProviderAuthWorkspaceDotEnvBlocklist(): ReadonlySet { + const keys = new Set(BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS); + for (const rawKey of listKnownProviderAuthEnvVarNames({ + includeUntrustedWorkspacePlugins: false, + })) { + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (key) { + keys.add(key.toUpperCase()); + } + } + return keys; +} + +function shouldBlockWorkspaceDotEnvKey( + key: string, + getProviderAuthBlockedKeys: () => ReadonlySet, +): boolean { const upper = key.toUpperCase(); return ( shouldBlockWorkspaceRuntimeDotEnvKey(upper) || BLOCKED_WORKSPACE_DOTENV_KEYS.has(upper) || BLOCKED_WORKSPACE_DOTENV_PREFIXES.some((prefix) => upper.startsWith(prefix)) || - BLOCKED_WORKSPACE_DOTENV_SUFFIXES.some((suffix) => upper.endsWith(suffix)) + BLOCKED_WORKSPACE_DOTENV_SUFFIXES.some((suffix) => upper.endsWith(suffix)) || + getProviderAuthBlockedKeys().has(upper) ); } @@ -180,9 +282,14 @@ function readDotEnvFile(params: { } export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boolean }) { + let providerAuthBlockedKeys: ReadonlySet | undefined; + const getProviderAuthBlockedKeys = () => { + providerAuthBlockedKeys ??= buildProviderAuthWorkspaceDotEnvBlocklist(); + return providerAuthBlockedKeys; + }; const parsed = readDotEnvFile({ filePath, - shouldBlockKey: shouldBlockWorkspaceDotEnvKey, + shouldBlockKey: (key) => shouldBlockWorkspaceDotEnvKey(key, getProviderAuthBlockedKeys), quiet: opts?.quiet ?? true, }); if (!parsed) {