Block provider credentials from workspace dotenv [AI] (#83655)

* fix: block provider credentials from workspace dotenv

* addressing codex review

* fix(dotenv): document provider credential sources

---------

Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
This commit is contained in:
Michael Appel
2026-05-28 10:57:57 -04:00
committed by GitHub
parent 9adbab05c6
commit 85277c2db1
6 changed files with 313 additions and 10 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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<string> {
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);
}
});
});

View File

@@ -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<void>) {
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 }) => {

View File

@@ -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<string> {
const keys = new Set<string>(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<string>,
): 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<string> | undefined;
const getProviderAuthBlockedKeys = () => {
providerAuthBlockedKeys ??= buildProviderAuthWorkspaceDotEnvBlocklist();
return providerAuthBlockedKeys;
};
const parsed = readDotEnvFile({
filePath,
shouldBlockKey: shouldBlockWorkspaceDotEnvKey,
shouldBlockKey: (key) => shouldBlockWorkspaceDotEnvKey(key, getProviderAuthBlockedKeys),
quiet: opts?.quiet ?? true,
});
if (!parsed) {