Models/Secrets: harden SecretRef-managed models.json persistence and audit#38470
Models/Secrets: harden SecretRef-managed models.json persistence and audit#38470
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟡 Secrets audit may read models.json outside stateDir via configurable agentDir (unrestricted file access + potential DoS)
Description
As a result:
Vulnerable code (path construction from config-controlled const agentDir = resolveAgentDir(config, agentId);
paths.add(path.join(resolveUserPath(agentDir), "models.json"));Sinks: const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw);RecommendationHarden models.json discovery and reading:
const stateRoot = resolveUserPath(stateDir);
for (const agentId of listAgentIds(config)) {
const id = normalizeAgentId(agentId);
paths.add(path.join(stateRoot, "agents", id, "agent", "models.json"));
}
const allowedRoot = path.join(resolveUserPath(stateDir), "agents") + path.sep;
const candidate = fs.realpathSync.native(path.join(resolveUserPath(agentDir), "models.json"));
if (!candidate.startsWith(allowedRoot)) return; // skip
const st = fs.statSync(candidate);
if (!st.isFile() || st.size > 5 * 1024 * 1024) return;
2. 🟡 Symlink/Hardlink following allows arbitrary file overwrite/chmod when writing models.json
DescriptionThe Key points:
Vulnerable code: await fs.writeFile(targetPath, next, { mode: 0o600 });
await ensureModelsFileMode(targetPath);
// ...
async function ensureModelsFileMode(pathname: string): Promise<void> {
await fs.chmod(pathname, 0o600).catch(() => {
// best-effort
});
}RecommendationHarden file writes/permission changes against symlink/hardlink attacks and make the write atomic. Recommended pattern:
Example (POSIX-oriented): import { constants as c } from "node:fs";
import { randomUUID } from "node:crypto";
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
const tmpPath = path.join(agentDir, `.models.json.${process.pid}.${randomUUID()}.tmp`);
const mode = 0o600;
// Write temp with O_EXCL + (when supported) O_NOFOLLOW
const flags = c.O_WRONLY | c.O_CREAT | c.O_EXCL | (c.O_NOFOLLOW ?? 0);
const handle = await fs.open(tmpPath, flags, mode);
try {
await handle.writeFile(next, "utf8");
await handle.chmod(mode).catch(() => {});
} finally {
await handle.close();
}
// rename replaces an existing symlink *itself* rather than following it
await fs.rename(tmpPath, targetPath);Alternatively, reuse existing repository helpers such as Also consider explicitly refusing to operate when 3. 🔵 Secrets audit may miss plaintext secrets in models.json due to overly broad env-var marker detection
Description
The new marker logic treats any ALL-CAPS/underscore/digit string (
Additionally, header values are skipped when they start with Vulnerable logic: } else if (isNonEmptyString(apiKey) && !isNonSecretApiKeyMarker(apiKey)) {
addFinding(... "PLAINTEXT_FOUND" ...)
}
...
if (isSecretRefHeaderValueMarker(headerValue)) {
continue;
}RecommendationMake audit’s suppression rules strict and evidence-based, so it avoids intended markers without creating blind spots:
Example hardening (illustrative): import { isValidEnvSecretRefId } from "../config/types.secrets.js";
import { isEnvVarNameMarker, isAwsSdkAuthMarker, NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js";
function isAuditSafeMarkerApiKey(value: string, knownEnvRefIds: Set<string>): boolean {
const v = value.trim();
if (!v) return false;
// Only fixed tokens / known markers
if (v === NON_ENV_SECRETREF_MARKER || isAwsSdkAuthMarker(v) || v === "minimax-oauth" || v === "qwen-oauth" || v === "ollama-local") {
return true;
}
// Only treat env var name as marker if it is actually referenced
return isEnvVarNameMarker(v) && knownEnvRefIds.has(v);
}
function isAuditSafeSecretRefEnvHeaderMarker(value: string): boolean {
const v = value.trim();
if (!v.startsWith("secretref-env:")) return false;
const suffix = v.slice("secretref-env:".length);
return isValidEnvSecretRefId(suffix);
}If you want to preserve the current UX (storing env var names in Analyzed PR: #38470 at commit Last updated on: 2026-03-07T03:11:52Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e0dfa16fb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (agentId === "main") { | ||
| paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); | ||
| continue; |
There was a problem hiding this comment.
Scan custom main agentDir models.json
When agents.list config defines id: "main" with a custom agentDir, this branch still hard-codes the default stateDir/agents/main/agent/models.json path and never calls resolveAgentDir for main. As a result, runSecretsAudit can miss plaintext or unresolved secrets in the real main agent models.json whenever that directory is outside the default state tree, creating a false “clean” audit result for exactly the new models.json checks added in this commit.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR systematically hardens the SecretRef-to- Key areas of the change:
Confidence Score: 3/5
Last reviewed commit: 4e0dfa1 |
| const preserved: Record<string, unknown> = {}; | ||
| if (typeof existing.apiKey === "string" && existing.apiKey) { | ||
| if ( | ||
| !secretRefManagedProviders.has(key) && | ||
| typeof existing.apiKey === "string" && | ||
| existing.apiKey | ||
| ) { | ||
| preserved.apiKey = existing.apiKey; | ||
| } |
There was a problem hiding this comment.
Stale non-env SecretRef marker preserved after transitioning to plaintext
When a provider previously had a non-env SecretRef (causing NON_ENV_SECRETREF_MARKER to be written into models.json), and the user then switches to a plaintext credential in their config, this merge step incorrectly preserves the stale marker.
Since secretRefManagedProviders does not include the provider in the new plaintext case, !secretRefManagedProviders.has(key) evaluates to true. preserved.apiKey gets set to the stale marker value from the file, and the { ...newEntry, ...preserved } spread on line 179 lets it override the freshly-normalized plaintext value written into newEntry.
At runtime, the provider ends up with an unresolvable marker string as its API credential, causing authentication failures.
The fix is to add a guard that skips preservation when the existing stored value is itself a non-secret marker. isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) from model-auth-markers.ts can serve this purpose — passing includeEnvVarName: false preserves env-var-name references (all-caps identifiers like PROVIDER_ENV_VAR) while blocking OAuth/local/non-env-ref marker strings from surviving the credential-type transition.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/models-config.ts
Line: 168-175
Comment:
**Stale non-env SecretRef marker preserved after transitioning to plaintext**
When a provider previously had a non-env SecretRef (causing `NON_ENV_SECRETREF_MARKER` to be written into `models.json`), and the user then switches to a plaintext credential in their config, this merge step incorrectly preserves the stale marker.
Since `secretRefManagedProviders` does not include the provider in the new plaintext case, `!secretRefManagedProviders.has(key)` evaluates to `true`. `preserved.apiKey` gets set to the stale marker value from the file, and the `{ ...newEntry, ...preserved }` spread on line 179 lets it override the freshly-normalized plaintext value written into `newEntry`.
At runtime, the provider ends up with an unresolvable marker string as its API credential, causing authentication failures.
The fix is to add a guard that skips preservation when the existing stored value is itself a non-secret marker. `isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false })` from `model-auth-markers.ts` can serve this purpose — passing `includeEnvVarName: false` preserves env-var-name references (all-caps identifiers like `PROVIDER_ENV_VAR`) while blocking OAuth/local/non-env-ref marker strings from surviving the credential-type transition.
How can I resolve this? If you propose a fix, please make it concise.…al routes (#38418) * fix(gateway): prevent webchat messages from cross-routing to external channels chat.send always originates from the webchat/control-UI surface. Previously, channel-scoped session keys (e.g. agent:main:slack:direct:U…) caused OriginatingChannel to inherit the session's stored external route, so the reply dispatcher would route responses to Slack/Telegram instead of back to the gateway connection. Remove the route-inheritance logic from chat.send and always set OriginatingChannel to INTERNAL_MESSAGE_CHANNEL ("webchat"). Closes #34647 Made-with: Cursor * Gateway: preserve configured-main connect gating * Gateway: cover connect-without-client routing * Gateway: add chat.send session key length limit * Gateway: cap chat.send session key schema * Gateway: bound chat.send session key parsing * Gateway: cover oversized chat.send session keys * Update CHANGELOG.md --------- Co-authored-by: SidQin-cyber <[email protected]>
* Config: add media retention TTL setting * Media: recurse persisted media cleanup * Gateway: add persisted media cleanup timer * Media: harden retention cleanup sweep * Media: make recursive retention cleanup opt-in * Media: retry writes after empty-dir cleanup race
…th (#38492) * fix(googlechat): inherit shared defaults from accounts.default * fix(googlechat): do not inherit default enabled state * fix(googlechat): avoid inheriting default credentials * fix(googlechat): keep dangerous auth flags account-local
…etwork errors (#38530) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: xinhuagu <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
* Web: add HEIC media normalization regression * Docs: list HEIC input_image MIME types * Update src/web/media.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
…38422) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: xinhuagu <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: yfge <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
2 similar comments
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
24 similar comments
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
Summary
This PR hardens the
models.json+ SecretRef surface end-to-end so SecretRef-managed provider credentials are never persisted as resolved secrets in generatedagents/*/agent/models.json, while preserving existing plaintext behavior for valid non-SecretRef setups.It also closes nearby gaps around provider header SecretRefs,
models listcommand-path persistence behavior, andsecrets auditvisibility for generatedmodels.jsonresidues.Aggregated Problem Surface Addressed
agents/*/agent/models.jsonfor SecretRef-managed providers.models listcommand paths could persist from resolved runtime config instead of source config.apiKeyvalues even when provider auth became SecretRef-managed.apiKeyfields.models.providers.*.headers.*SecretRef surfaces lacked full runtime/audit/persistence handling.secrets auditdid not fully cover generatedmodels.jsonprovider auth/header residues.models.jsonwrite paths needed stronger serialization and mode hardening.What Changed
modelscommand persistence paths.env ref,non-env ref,plaintext) instead of pattern-only logic.secretref-managed,secretref-env:*, OAuth/local markers) and marker-aware handling.apiKeyonly for providers not SecretRef-managed in current config/auth-profile context.models.providers.*.headers.*SecretRef support in collectors/registry/runtime sanitation/audit.models.jsonwrites with per-path serialization and file mode enforcement (0600) on write and no-content-change paths.secrets auditto inspect generatedagents/*/agent/models.jsonfor unresolved SecretRef objects and plaintext-sensitive residues.Behavioral Guarantees Preserved
Related Cluster (Issues/PRs)
This PR relates to the same cluster identified in the audit report:
models.providers.*.headers.*SecretRef support)Validation
pnpm checkpnpm build