Skip to content

security: redact resolved API keys from models.json#28645

Closed
dsantoreis wants to merge 8 commits intoopenclaw:mainfrom
dsantoreis:fix/secretref-models-json-leak
Closed

security: redact resolved API keys from models.json#28645
dsantoreis wants to merge 8 commits intoopenclaw:mainfrom
dsantoreis:fix/secretref-models-json-leak

Conversation

@dsantoreis
Copy link
Copy Markdown
Contributor

Summary

Fixes #28359 — SecretRef and environment variable values were resolved to plaintext during provider normalization and persisted to ~/.openclaw/agents/*/agent/models.json, defeating at-rest secret protection.

Problem

After running openclaw secrets configure && openclaw secrets apply, the gateway resolves SecretRef entries to their plaintext values and writes them directly to models.json:

{
  "providers": {
    "openai": {
      "apiKey": "sk-proj-abc123..."
    }
  }
}

This means every models.json on disk contains plaintext API keys — a security risk, especially on multi-user systems or when backups include the ~/.openclaw/agents/ directory.

Fix

Added redactApiKeysForPersistence() that strips resolved API keys before writing models.json:

  • Environment variable names (e.g. OPENAI_API_KEY) are preserved — they are safe references, not secrets
  • Resolved values (e.g. sk-proj-..., hf_...) are omitted entirely
  • The runtime auth chain (profiles → env → config → SecretRef) re-resolves keys on demand, so models.json doesn't need plaintext secrets
  • Existing leaked models.json files are overwritten on next gateway start

Detection

Uses /^[A-Z][A-Z0-9_]{1,127}$/ to distinguish env var names from resolved secrets. This is the same pattern used elsewhere in the codebase (ENV_SECRET_TEMPLATE_RE).

Files Changed (1 file, +44/-1 lines)

File Change
src/agents/models-config.ts Add redactApiKeysForPersistence(), apply before JSON.stringify

Validation

  • pnpm build
  • tsc --noEmit ✅ (zero type errors)
  • oxfmt

Risk Assessment

  • Low risk: only affects the persistence layer, not runtime auth
  • Zero breaking change: env var names are preserved; resolved keys are re-resolved at runtime via existing auth chain
  • Self-healing: existing leaked files are cleaned up on next write

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: XS labels Feb 27, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd2e5fde98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/models-config.ts Outdated
Comment on lines +129 to +132
if (typeof apiKey === "string" && apiKey.trim() && !ENV_VAR_NAME_RE.test(apiKey.trim())) {
// This apiKey is a resolved secret (not an env var name) — omit it.
const { apiKey: _stripped, ...rest } = entry as Record<string, unknown>;
redacted[key] = rest as ProviderConfig;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep a placeholder apiKey instead of removing it

Removing apiKey outright for any non-env-var value can break profile-authenticated providers during model discovery. resolveImplicitProviders() and normalizeProviders() intentionally populate apiKey from auth profiles because providers that define models need that field for pi model registration (src/agents/models-config.providers.ts), but this redaction deletes those profile-derived values before models.json is consumed by ModelRegistry in resolveModel() (src/agents/pi-embedded-runner/model.ts). In environments using auth profiles (and no provider env var), this can cause provider models to disappear and surface as Unknown model errors.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Feb 27, 2026

Greptile Summary

Addresses a security vulnerability where resolved API keys were persisted in plaintext to ~/.openclaw/agents/*/agent/models.json. The fix adds a redactApiKeysForPersistence() function that strips resolved secret values before writing to disk while preserving environment variable names (which are safe references).

Key changes:

  • Added ENV_VAR_NAME_RE regex pattern (/^[A-Z][A-Z0-9_]{1,127}$/) to distinguish env var names from resolved secrets
  • Implemented redactApiKeysForPersistence() to strip non-env-var-name apiKey values from provider entries
  • Applied redaction before JSON.stringify() in ensureOpenClawModelsJson()
  • Self-healing: existing leaked models.json files are cleaned up on next gateway start

Security impact:

  • Prevents plaintext API keys from being written to disk
  • Runtime auth chain (profiles → env → config → SecretRef) continues to resolve keys on demand
  • Zero breaking change: env var names are preserved and existing tests pass

How it works:
The regex pattern identifies uppercase environment variable names like OPENAI_API_KEY and preserves them. Resolved secrets like sk-proj-abc123... or hf_token123 don't match the pattern and are stripped before persistence. This approach is consistent with the existing ENV_SECRET_TEMPLATE_RE pattern used elsewhere in the codebase.

Confidence Score: 4/5

  • This PR is safe to merge with minimal risk - addresses a real security vulnerability without breaking existing functionality
  • Score reflects a solid security fix with correct implementation. The redaction logic properly distinguishes between env var names (preserved) and resolved secrets (stripped). Runtime auth chain remains functional since it re-resolves keys on demand. Existing tests use env var names and should pass unchanged. Minor deduction for lack of dedicated unit tests for the new security-critical function, though the implementation aligns with existing codebase patterns.
  • No files require special attention - the single changed file has a straightforward implementation

Last reviewed commit: bd2e5fd

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +116 to +138
function redactApiKeysForPersistence(
providers: ModelsConfig["providers"],
): ModelsConfig["providers"] {
if (!providers) {
return providers;
}
const redacted: Record<string, ProviderConfig> = {};
for (const [key, entry] of Object.entries(providers)) {
if (!entry) {
redacted[key] = entry;
continue;
}
const apiKey = (entry as Record<string, unknown>).apiKey;
if (typeof apiKey === "string" && apiKey.trim() && !ENV_VAR_NAME_RE.test(apiKey.trim())) {
// This apiKey is a resolved secret (not an env var name) — omit it.
const { apiKey: _stripped, ...rest } = entry as Record<string, unknown>;
redacted[key] = rest as ProviderConfig;
} else {
redacted[key] = entry;
}
}
return redacted;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding unit tests for this security-critical function to verify:

  • Env var names preserved (e.g., OPENAI_API_KEY)
  • Resolved secrets stripped (e.g., sk-proj-..., hf_...)
  • Edge cases handled (empty strings, whitespace, single char)
  • Other provider fields preserved (baseUrl, models, etc.)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/models-config.ts
Line: 116-138

Comment:
Consider adding unit tests for this security-critical function to verify:
- Env var names preserved (e.g., `OPENAI_API_KEY`)
- Resolved secrets stripped (e.g., `sk-proj-...`, `hf_...`)
- Edge cases handled (empty strings, whitespace, single char)
- Other provider fields preserved (`baseUrl`, `models`, etc.)

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c1a56b609

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* (e.g. "OPENAI_API_KEY"). These are safe to persist in models.json because
* they are *references*, not the secret value itself.
*/
const ENV_VAR_NAME_RE = /^[A-Z][A-Z0-9_]{1,127}$/;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact uppercase API secrets before persisting models.json

The redaction guard treats any all-caps token matching ^[A-Z][A-Z0-9_]{1,127}$ as an env-var reference, so literal API keys in that shape are written to disk unchanged. This means users with uppercase-only credentials (for example custom proxy tokens) still leak plaintext secrets into models.json, which defeats the security fix for that input class.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5cac67fac6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

continue;
}
const apiKey = (entry as Record<string, unknown>).apiKey;
if (typeof apiKey === "string" && apiKey.trim() && !ENV_VAR_NAME_RE.test(apiKey.trim())) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact uppercase API key literals before persisting

redactApiKeysForPersistence() currently treats any all-caps token matching ^[A-Z][A-Z0-9_]{1,127}$ as an environment-variable reference and keeps it in models.json. If a resolved/profile API key itself is uppercase (for example proxy or enterprise tokens), it will be written to disk in plaintext, so the at-rest secret redaction introduced in this commit is bypassed for that credential shape.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75cfeeaf59

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/models-config.ts Outdated
* all-uppercase API keys that happen to pass the regex (e.g. custom proxy tokens).
*/
function isEnvVarReference(value: string): boolean {
return ENV_VAR_NAME_RE.test(value) && value in process.env;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve env-var API key refs even when unset

isEnvVarReference() now requires the token to exist in process.env, so a valid configured reference like apiKey: "OPENAI_API_KEY" is treated as a secret and rewritten to REDACTED whenever ensureOpenClawModelsJson() runs in an environment where that var is not currently exported (common for different launch contexts). Because merge mode preserves any non-empty existing apiKey, that redacted value then keeps winning on later writes, so the original reference cannot recover even after the env var is restored; this can break workflows that rely on persisted env-var references rather than profile keys.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0a1d7a48d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

continue;
}
const apiKey = (entry as Record<string, unknown>).apiKey;
if (typeof apiKey === "string" && apiKey.trim() && !ENV_VAR_NAME_RE.test(apiKey.trim())) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact uppercase API key literals before writing models.json

Treating any token that matches ^[A-Z][A-Z0-9_]{1,127}$ as an env-var reference allows real API keys with uppercase/underscore formats to be persisted in plaintext. In configurations where a resolved/profile key happens to use that shape (for example enterprise proxy tokens), redactApiKeysForPersistence() skips replacement and writes the secret to models.json, which bypasses the at-rest redaction this change is intended to enforce.

Useful? React with 👍 / 👎.

@dsantoreis

This comment was marked as spam.

Daniel dos Santos Reis added 8 commits February 28, 2026 11:47
SecretRef and environment variable values were resolved to plaintext
during provider normalization and then persisted to
~/.openclaw/agents/*/agent/models.json, defeating the purpose of
at-rest secret protection.

This commit strips resolved API keys before writing models.json.
Environment variable *names* (e.g. OPENAI_API_KEY) are preserved as
safe references, but actual key values (sk-..., hf_..., etc.) are
omitted. The runtime auth chain (profiles → env → config → SecretRef)
re-resolves keys on demand, so models.json does not need plaintext
secrets.

Existing models.json files with leaked keys are overwritten on next
gateway start (mode=replace) or config change (mode=merge) — the merge
path no longer copies plaintext apiKey values from the existing file.

Fixes #28359
Addresses Codex review feedback: ModelRegistry requires the apiKey
field to be present for provider registration. Deleting it entirely
could cause 'Unknown model' errors for providers discovered via
auth profiles.

Now replaces resolved secrets with "REDACTED" placeholder instead
of omitting the field.
Verifies that:
- Resolved secret values (sk-...) are replaced with REDACTED placeholder
- Environment variable names (UPPERCASE_PATTERN) are preserved as safe references

Addresses Greptile review feedback requesting tests for the security-critical
redaction function.
Fixes CI tsgo type check failure — ModelDefinitionConfig requires
reasoning, input, cost, contextWindow, and maxTokens fields.
…erence

Addresses Codex review: all-uppercase API keys (e.g. custom proxy tokens)
could pass the ENV_VAR_NAME_RE regex and leak to disk. Now checks both
the naming pattern AND existence in process.env.

Also adds a third test case for uppercase tokens not in env, and fixes
tsgo type compatibility for ModelDefinitionConfig in tests.
The process.env check broke existing tests where CONFIG_KEY (a literal
apiKey from config, not an env var) was being redacted because it wasn't
in the CI environment. Regex-only detection preserves any UPPER_CASE
value as a safe reference, which matches the existing test expectations.

Fixes CI: checks-windows and checks (bun) failures.
- Verify other provider fields (baseUrl, models, api) are preserved
- Handle empty string apiKey without crashing
- Test various secret key formats (sk-proj-*)
@steipete
Copy link
Copy Markdown
Contributor

steipete commented Mar 3, 2026

Thanks for the PR! Multiple PRs address issue #28359. Keeping #28393 as the earliest submission. Closing to reduce noise. This is an AI-assisted triage review. If we got this wrong, feel free to reopen — happy to revisit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: SecretRef keys are re-materialized as plaintext in ~/.openclaw/agents/*/agent/models.json after runtime load

2 participants