Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4ee8f6a
feat(provider-security): ChefVault client, in-memory slots, degraded …
OnlineChef Aug 1, 2026
805bfd6
feat(provider-security): ChefVault client, slots, and degraded mode
OnlineChef Aug 1, 2026
32910a3
fix(ci): refresh gui bun.lock for frozen install
OnlineChef Aug 1, 2026
fea8ce1
fix(ci): unblock actionlint SC2129 and keep gui lockfile frozen
OnlineChef Aug 1, 2026
7c89885
fix(provider-security): lease chefvault credentials on every provider…
OnlineChef Aug 1, 2026
86b1619
fix(provider-security): send ChefVault bearer token and split doctor …
OnlineChef Aug 1, 2026
6d99b0c
chore(provider-security): drop unrelated workflow/lockfile churn from…
OnlineChef Aug 1, 2026
7ddf206
fix(provider-security): align doctor auth assertions and auth_forbidd…
OnlineChef Aug 1, 2026
23cff74
fix(tests): use allowlisted bearer fixture token for privacy scan
OnlineChef Aug 1, 2026
3d204a5
fix(provider-security): harden lease outages, revocation, and status …
Aug 2, 2026
3cde02b
fix(provider-security): enforce degraded gate, ref-wide revocation, d…
OnlineChef Aug 2, 2026
ff63915
test(images): cover ChefVault lease failure mapping for the Images pr…
OnlineChef Aug 2, 2026
1248c6a
test(provider-security): exercise real ChefVault HTTP contract
OnlineChef Aug 2, 2026
c9c89df
ci(provider-security): verify pinned ChefVault contract
OnlineChef Aug 2, 2026
5f058c9
ci(provider-security): run contract on security branches
OnlineChef Aug 2, 2026
ac7aafb
ci(provider-security): gate ChefVault contract on cross-repo token
OnlineChef Aug 2, 2026
4a9f139
ci(provider-security): keep live contract in ChefVault
OnlineChef Aug 2, 2026
cfd83f3
test(provider-security): move live server contract to Vault
OnlineChef Aug 2, 2026
be90f9c
Merge cfd83f3870d7e0fa40a6f06797930c7c2905a8f5 into 0c9127fb76175769b…
OnlineChef Aug 2, 2026
8455906
Merge current dev into feat/provider-security-plane
OnlineChef Aug 2, 2026
acffee8
fix(provider-security): gate renewal outages and redact data-plane le…
OnlineChef Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
resolveCodexRuntime,
} from "../codex/runtime";
import { CODEX_REAUTH_ACTION, collectOAuthHealthEntriesForCli, MASKED_ACCOUNT_FALLBACK, type OAuthHealthEntry } from "../oauth/health";
import { collectProviderSecurityDoctorChecks } from "../provider-security/status";
import { getAuthRefreshIntentLockPath, getAuthStorePath } from "../oauth/store";
export { resolveCodexHomeDir } from "../codex/home";

Expand Down Expand Up @@ -817,6 +818,11 @@ export async function runDoctor(args: string[] = []): Promise<void> {
console.log(` [${check.level}] ${check.message}`);
}

console.log("\nProvider security (ChefVault)");
for (const check of await collectProviderSecurityDoctorChecks(doctorConfig)) {
console.log(` [${check.level}] ${check.message}`);
}

// Hints, not fixes.
const hints: string[] = [];
const proxyDown = proxyDownRestartHint({
Expand Down
5 changes: 5 additions & 0 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortCla
import { redactSecretString, redactUserPath } from "../lib/redact";
import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home";
import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status";
import { collectProviderSecurityStatusAsync, type ProviderSecurityStatusReport } from "../provider-security/status";

type HealthCheck = {
ok: boolean;
Expand Down Expand Up @@ -68,6 +69,7 @@ export type CliStatusJson = {
};
};
codexHome: OrcaCodexHomeDiagnostic;
providerSecurity: ProviderSecurityStatusReport;
};

export type CliStatusView = {
Expand Down Expand Up @@ -270,6 +272,8 @@ export async function collectStatus(): Promise<CliStatusView> {
? "reachable, but PID file is missing or stale"
: "not running";

const providerSecurity = await collectProviderSecurityStatusAsync(config);

return {
proxyLabel,
healthLabel: health.label,
Expand Down Expand Up @@ -311,6 +315,7 @@ export async function collectStatus(): Promise<CliStatusView> {
codexPlugins,
codexRuntime,
codexHome,
providerSecurity,
},
};
}
13 changes: 7 additions & 6 deletions src/images/plan.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
import type { ImageBridgePlan, VideoBridgePlan } from "./types";
import { resolveEnvValue } from "../config";
import { tryResolveProviderApiKey } from "../providers/credential";
import { getProviderRegistryEntry } from "../providers/registry";
import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isVideoGenName } from "./synthetic-tool";

Expand Down Expand Up @@ -33,10 +33,11 @@ export function findXaiProvider(config: OcxConfig): { name: string; provider: Oc
* OAuth / Grok CLI proxy transport is not used here (that path is chat-oriented and not a
* supported Images transport), so oauth-only configs deliberately do not arm the bridge.
*/
export function resolveXaiImageApiKey(provider: OcxProviderConfig): string | undefined {
export async function resolveXaiImageApiKey(provider: OcxProviderConfig): Promise<string | undefined> {
if (provider.authMode === "oauth") return undefined;
const apiKey = resolveEnvValue(provider.apiKey)?.trim();
return apiKey || undefined;
// Covers both inline keys and chefvault:// references, so a reference-backed xAI provider
// arms the bridge instead of silently looking unconfigured.
return tryResolveProviderApiKey(provider);
}

export async function planImageBridge(
Expand All @@ -51,7 +52,7 @@ export async function planImageBridge(
if (host === "api.openai.com") return undefined;
const found = findXaiProvider(config);
if (!found) return undefined;
const token = resolveXaiImageApiKey(found.provider);
const token = await resolveXaiImageApiKey(found.provider);
if (!token) return undefined;
// Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override.
const registryEntry = getProviderRegistryEntry("xai");
Expand Down Expand Up @@ -100,7 +101,7 @@ export async function planVideoBridge(
if (host === "api.openai.com") return undefined;
const found = findXaiProvider(config);
if (!found) return undefined;
const token = resolveXaiImageApiKey(found.provider);
const token = await resolveXaiImageApiKey(found.provider);
if (!token) return undefined;
// Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override.
const registryEntry = getProviderRegistryEntry("xai");
Expand Down
10 changes: 10 additions & 0 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry";
import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
import { resolveProviderTransport } from "../providers/xai-transport";
import { globalProviderCredentialResolver } from "../provider-security/resolve";
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
import { logOAuthEvent } from "./log";
export {
Expand Down Expand Up @@ -465,6 +466,15 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
return undefined;
}
}
const credentialRef = prov.credentialRef?.trim();
if (credentialRef) {
try {
const resolved = await globalProviderCredentialResolver.resolveCredentialRef(credentialRef);
return resolved.apiKey;
} catch {
return undefined;
Comment thread
OnlineChef marked this conversation as resolved.
}
}
return resolveEnvValue(prov.apiKey);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
267 changes: 267 additions & 0 deletions src/provider-security/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* ChefVault provider-security HTTP client (PSP-008).
*
* Talks to the secret authority at CHEF_PROVIDER_SECURITY_URL (default :8323).
* Protected routes require Authorization: Bearer from CHEF_PROVIDER_SECURITY_TOKEN.
* Workload identity headers are assertions only (must match the token binding).
*/
import {
type ChefVaultRenewRequest,
type ChefVaultRenewResponse,
type ChefVaultResolveRequest,
type ChefVaultResolveResponse,
ProviderSecurityError,
type ProviderSecurityClientConfig,
type WorkloadIdentity,
} from "./types";

export const DEFAULT_PROVIDER_SECURITY_URL = "http://127.0.0.1:8323";

const WORKLOAD_HEADER_WORKLOAD = "x-chef-workload-id";
const WORKLOAD_HEADER_HOST = "x-chef-host-id";
const WORKLOAD_HEADER_ACTOR = "x-chef-actor";

export function resolveProviderSecurityUrl(env: NodeJS.ProcessEnv = process.env): string {
const raw = env.CHEF_PROVIDER_SECURITY_URL?.trim();
return raw || DEFAULT_PROVIDER_SECURITY_URL;
}

export function resolveProviderSecurityToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
const raw = env.CHEF_PROVIDER_SECURITY_TOKEN?.trim();
return raw || undefined;
}

export function resolveWorkloadIdentity(env: NodeJS.ProcessEnv = process.env): WorkloadIdentity {
return {
workloadId: env.CHEF_WORKLOAD_ID?.trim() || "opencodex",
hostId: env.CHEF_HOST_ID?.trim() || env.HOSTNAME?.trim() || "local",
actor: env.CHEF_ACTOR?.trim() || "opencodex",
};
}

function workloadHeaders(workload: WorkloadIdentity): Record<string, string> {
return {
[WORKLOAD_HEADER_WORKLOAD]: workload.workloadId,
[WORKLOAD_HEADER_HOST]: workload.hostId,
[WORKLOAD_HEADER_ACTOR]: workload.actor,
};
}

function mapAuthorityError(status: number, body: unknown): ProviderSecurityError {
const record = body && typeof body === "object" && !Array.isArray(body)
? body as Record<string, unknown>
: {};
const code = typeof record.code === "string" ? record.code : undefined;
const message = typeof record.message === "string"
? record.message
: typeof record.error === "string"
? record.error
: `ChefVault provider-security returned HTTP ${status}`;

if (code === "stale_fencing_token") {
return new ProviderSecurityError("stale_fencing_token", message);
}
if (status === 401 || code === "auth_required" || code === "auth_invalid") {
return new ProviderSecurityError(
code === "auth_invalid" ? "auth_invalid" : "auth_required",
message,
);
}
if (
status === 403
|| code === "identity_assertion_mismatch"
|| code === "admin_required"
|| code === "workload_credential_required"
) {
return new ProviderSecurityError(
code === "identity_assertion_mismatch" ? "identity_assertion_mismatch" : "auth_forbidden",
message,
);
}
if (code === "auth_not_configured") {
return new ProviderSecurityError("auth_not_configured", message);
}
if (status === 404 || code === "ref_not_found") {
return new ProviderSecurityError("ref_not_found", message);
}
if (status === 400 || code === "ref_invalid") {
return new ProviderSecurityError("ref_invalid", message);
}
if (status === 410 || code === "revoked") {
return new ProviderSecurityError("revoked", message);
}
// An authority that answers 5xx is up but cannot serve; that is an outage, not a rejected
// credential, and only outages are allowed to trip degraded mode.
if (status >= 500) {
return new ProviderSecurityError("authority_unavailable", message);
}
return new ProviderSecurityError("authority_error", message);
}

function parseLeaseFields(body: unknown, kind: "resolve" | "renew"): {
record: Record<string, unknown>;
lease: ChefVaultRenewResponse;
} {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new ProviderSecurityError("authority_error", `${kind} response was not an object`);
}
const record = body as Record<string, unknown>;
const leaseId = typeof record.leaseId === "string" ? record.leaseId : "";
const secret = typeof record.secret === "string" ? record.secret : "";
const expiresAt = typeof record.expiresAt === "number" ? record.expiresAt : Number(record.expiresAt);
const fencingToken = typeof record.fencingToken === "number" ? record.fencingToken : Number(record.fencingToken);
if (!leaseId || !secret || !Number.isFinite(expiresAt) || !Number.isFinite(fencingToken)) {
throw new ProviderSecurityError("authority_error", `${kind} response missing required lease fields`);
}
return { record, lease: { leaseId, secret, expiresAt, fencingToken } };
}

function parseResolveResponse(body: unknown): ChefVaultResolveResponse {
const { record, lease } = parseLeaseFields(body, "resolve");
const slotHint = record.slotHint;
return {
...lease,
...(slotHint === "active" || slotHint === "next" || slotHint === "retiring"
? { slotHint }
: {}),
};
}

function parseRenewResponse(body: unknown): ChefVaultRenewResponse {
return parseLeaseFields(body, "renew").lease;
}

export class ProviderSecurityClient {
readonly baseUrl: string;
readonly workload: WorkloadIdentity;
readonly token: string | undefined;
private readonly fetchImpl: typeof fetch;
private readonly requestTimeoutMs: number;

constructor(config: ProviderSecurityClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, "");
this.workload = config.workload;
this.token = config.token?.trim() || undefined;
this.fetchImpl = config.fetchImpl ?? fetch;
this.requestTimeoutMs = config.requestTimeoutMs ?? 8_000;
}

static fromEnv(env: NodeJS.ProcessEnv = process.env): ProviderSecurityClient {
return new ProviderSecurityClient({
baseUrl: resolveProviderSecurityUrl(env),
workload: resolveWorkloadIdentity(env),
token: resolveProviderSecurityToken(env),
});
}

private requireToken(): string {
if (!this.token) {
throw new ProviderSecurityError(
"auth_required",
"CHEF_PROVIDER_SECURITY_TOKEN is required for ChefVault credential calls",
);
}
return this.token;
}

private async request(
path: string,
init: RequestInit,
opts: { auth: boolean },
): Promise<{ ok: boolean; status: number; body: unknown }> {
const headers: Record<string, string> = {
accept: "application/json",
"content-type": "application/json",
...workloadHeaders(this.workload),
...(init.headers as Record<string, string> | undefined ?? {}),
};
// Resolve the token before arming the abort timer: requireToken() throws for
// unauthenticated calls, and a timer armed before that throw would leak and keep a
// short-lived process (e.g. the doctor CLI) alive until it fires.
if (opts.auth) {
headers.authorization = `Bearer ${this.requireToken()}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
try {
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
...init,
signal: controller.signal,
headers,
});
// Read the body while the timeout is still armed: a response that stalls mid-body would
// otherwise hang a credential call for as long as the authority keeps the socket open.
const body = await response.json().catch(() => null);
return { ok: response.ok, status: response.status, body };
} catch (error) {
if (error instanceof ProviderSecurityError) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new ProviderSecurityError("network_error", "ChefVault provider-security request timed out");
}
throw new ProviderSecurityError(
"authority_unavailable",
error instanceof Error ? error.message : "ChefVault provider-security is unreachable",
);
} finally {
clearTimeout(timer);
}
}

/** Unauthenticated liveness probe. */
async healthz(): Promise<{ ok: boolean; message: string }> {
try {
const { ok, status } = await this.request("/healthz", { method: "GET" }, { auth: false });
if (!ok) {
return { ok: false, message: `HTTP ${status}` };
}
return { ok: true, message: "ok" };
} catch (error) {
if (error instanceof ProviderSecurityError) {
return { ok: false, message: error.message };
}
return { ok: false, message: "unreachable" };
}
}

/**
* Authenticated readiness: Bearer accepted on a protected route.
* Uses GET /provider-security/status (redacted; no secrets).
*/
async authenticatedReady(): Promise<{ ok: boolean; message: string }> {
try {
const { ok, status, body } = await this.request("/provider-security/status", { method: "GET" }, { auth: true });
if (ok) {
return { ok: true, message: "authenticated" };
}
const mapped = mapAuthorityError(status, body);
return { ok: false, message: `${mapped.code}: ${mapped.message}` };
} catch (error) {
if (error instanceof ProviderSecurityError) {
return { ok: false, message: `${error.code}: ${error.message}` };
}
return { ok: false, message: "unreachable" };
}
}

async resolveLease(input: ChefVaultResolveRequest): Promise<ChefVaultResolveResponse> {
const { ok, status, body } = await this.request("/v1/credentials/resolve", {
method: "POST",
body: JSON.stringify(input),
}, { auth: true });
if (!ok) {
throw mapAuthorityError(status, body);
}
return parseResolveResponse(body);
}

async renewLease(input: ChefVaultRenewRequest): Promise<ChefVaultRenewResponse> {
const { ok, status, body } = await this.request("/v1/credentials/renew", {
method: "POST",
body: JSON.stringify(input),
}, { auth: true });
if (!ok) {
throw mapAuthorityError(status, body);
}
return parseRenewResponse(body);
}
}
Loading
Loading