Skip to content

Commit 4bf70be

Browse files
authored
feat(secrets): egress-time credential injection with process-local sentinels (#102009)
* feat(secrets): resolve SecretRef model credentials at egress via process-local sentinels SecretRef-managed model-provider credentials now travel as opaque oc-sent-v1 sentinels through auth storage, stream options, and SDK config; the guarded model fetch injects real values into headers and URLs immediately before the SSRF-guarded send and fails closed on unknown sentinels. packages/ai adapters converge on the host guarded fetch where the SDK supports custom fetch and unwrap at construction where it does not. Resolved values (and their percent-encoded forms) register for exact-value log redaction. Kill switch: OPENCLAW_SECRET_SENTINELS=off. Also fixes a pre-existing unhandled rejection race in capNonOkResponseBodyLazily (pipeThrough writer leak). * test(plugin-sdk): update public surface budget
1 parent 83ebbcb commit 4bf70be

67 files changed

Lines changed: 2956 additions & 231 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26+
- **SecretRef model credentials:** keep resolved provider secrets behind process-local sentinels through auth storage, stream setup, SDK configuration, and managed local-provider probing, then inject plaintext only at the final network or provider-plugin boundary while retaining exact-value log redaction. (#102008, #102009)
2627
- **Lean local model shell access:** keep `exec` directly visible beside the default structured Tool Search controls so coding-tuned local models can use their shell fallback instead of searching for missing domain tools. (#87587) Thanks @vincentkoc.
2728
- **OAuth refresh contention diagnostics:** keep local lock paths out of user-facing refresh failures and avoid duplicate failure prefixes while preserving structured provider and profile classification. (#83383) Thanks @vincentkoc.
2829
- **Exec approval prompts:** keep background-disabled fallback warnings out of pending gateway/node approvals and show them only after a command actually runs in the foreground. (#78184) Thanks @vincentkoc.

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3710,6 +3710,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
37103710
- Route: /gateway/secrets
37113711
- Headings:
37123712
- H2: Runtime model
3713+
- H2: Egress-time injection (sentinels)
37133714
- H2: Agent-access boundary
37143715
- H2: Active-surface filtering
37153716
- H2: Gateway auth surface diagnostics

docs/gateway/secrets.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,25 @@ Plaintext credentials remain agent-readable if they sit in files the agent can i
2424
- Startup fails fast when an effectively active SecretRef cannot be resolved.
2525
- Reload is an atomic swap: full success, or keep the last-known-good snapshot.
2626
- Policy violations (for example an OAuth-mode auth profile combined with SecretRef input) fail activation before the runtime swap.
27-
- Runtime requests read only the active in-memory snapshot. Outbound delivery paths (Discord reply/thread delivery, Telegram action sends) also read that snapshot and do not re-resolve refs per send.
27+
- Runtime requests read only the active in-memory snapshot. Model-provider SecretRef credentials pass through auth storage and stream options as process-local sentinels until egress. Outbound delivery paths (Discord reply/thread delivery, Telegram action sends) also read that snapshot and do not re-resolve refs per send.
2828

2929
This keeps secret-provider outages off hot request paths.
3030

31+
## Egress-time injection (sentinels)
32+
33+
For model-provider credentials backed by SecretRefs, OpenClaw mints an opaque, process-local sentinel during model-auth resolution. Auth storage, stream options, SDK configuration, logs, error objects, and most runtime introspection therefore see a value such as `oc-sent-v1-...`, not the provider credential. The guarded model fetch and managed local-provider health probes replace known sentinels in URL and header values immediately before each request leaves the process.
34+
35+
Unknown sentinel-shaped values fail closed before network activity. OpenClaw refuses to send the request rather than forwarding an unresolved sentinel to a provider. Resolved secret values are also registered for exact-value log redaction as a defense in depth measure.
36+
37+
Provider adapters use the latest injection point their SDK supports:
38+
39+
- SDKs with a custom fetch option receive OpenClaw's guarded fetch, so the SDK retains the sentinel.
40+
- SDKs without a custom fetch option unwrap the sentinel immediately before client construction. Plugin-owned provider streams and agent harnesses unwrap at the final core-owned handoff because those transports do not share OpenClaw's guarded fetch.
41+
42+
Sentinels reduce plaintext exposure across the model-call chain, but they are not process isolation. The real value still exists in same-process memory and appears at the final adapter boundary. Plain environment credentials that are not configured through SecretRefs remain plaintext and are outside this mechanism.
43+
44+
Set `OPENCLAW_SECRET_SENTINELS=off` (also accepts `0` or `false`, case-insensitive) to disable sentinel minting during incident response or compatibility troubleshooting. The kill switch does not disable exact-value redaction registration.
45+
3146
## Agent-access boundary
3247

3348
SecretRefs stop credentials from being persisted in config and generated model files, but they are not a process-isolation boundary. A plaintext credential left on disk in a path the agent can read is still readable via file or shell tools, bypassing API-level redaction.

packages/ai/src/host.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface AiTransportHost {
2222
timeoutMs?: number,
2323
options?: { sanitizeSse?: boolean },
2424
): typeof fetch | undefined;
25+
/** Resolves host-owned process-local secret sentinel substrings immediately before egress. */
26+
resolveSecretSentinel(value: string): string;
2527
/** Redacts secrets inside structured tool-result payloads. */
2628
redactSecrets<T>(value: T): T;
2729
/** Redacts secret-bearing text in tool payload strings. */
@@ -46,6 +48,7 @@ export interface AiTransportHost {
4648

4749
const inertAiTransportHost: AiTransportHost = {
4850
buildModelFetch: () => undefined,
51+
resolveSecretSentinel: (value) => value,
4952
redactSecrets: (value) => value,
5053
redactToolPayloadText: (text) => text,
5154
resolveOpenAIStrictToolSetting: (_model, options) =>
@@ -64,3 +67,22 @@ export function configureAiTransportHost(host: Partial<AiTransportHost>): void {
6467
export function getAiTransportHost(): AiTransportHost {
6568
return activeAiTransportHost;
6669
}
70+
71+
/** Resolves sentinel substrings in custom headers at a no-fetch adapter boundary. */
72+
export function resolveAiTransportHeaderSentinels(
73+
headers: Record<string, string> | undefined,
74+
): Record<string, string> | undefined {
75+
if (!headers) {
76+
return undefined;
77+
}
78+
const host = getAiTransportHost();
79+
let resolvedHeaders: Record<string, string> | undefined;
80+
for (const [name, value] of Object.entries(headers)) {
81+
const resolved = host.resolveSecretSentinel(value);
82+
if (resolved !== value) {
83+
resolvedHeaders ??= { ...headers };
84+
resolvedHeaders[name] = resolved;
85+
}
86+
}
87+
return resolvedHeaders ?? headers;
88+
}

packages/ai/src/providers/anthropic.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,35 @@ describe("Anthropic provider", () => {
151151
expect(config.defaultHeaders?.["x-api-key"]).toBeUndefined();
152152
});
153153

154+
it("keeps sentinel-backed Foundry Authorization headers on bearer routing", async () => {
155+
const sentinel = "oc-sent-v1-0123456789abcdef01234567";
156+
configureAiTransportHost({
157+
buildModelFetch: () => async () => new Response(null, { status: 500 }),
158+
resolveSecretSentinel: (value) => value.replaceAll(sentinel, "Bearer entra-access-token"),
159+
});
160+
const model = makeAnthropicModel({
161+
provider: "microsoft-foundry",
162+
baseUrl: "https://example.services.ai.azure.com/anthropic",
163+
headers: { Authorization: sentinel },
164+
});
165+
166+
streamAnthropic(
167+
model,
168+
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
169+
{
170+
apiKey: sentinel,
171+
},
172+
);
173+
174+
await vi.waitFor(() => expect(anthropicMockState.configs).toHaveLength(1));
175+
const config = anthropicMockState.configs[0] as {
176+
apiKey?: string | null;
177+
authToken?: string | null;
178+
};
179+
expect(config.apiKey).toBeNull();
180+
expect(config.authToken).toBe(sentinel);
181+
});
182+
154183
it("keeps Microsoft Foundry API-key profiles on Anthropic API key auth", async () => {
155184
const model = makeAnthropicModel({
156185
provider: "microsoft-foundry",

packages/ai/src/providers/anthropic.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
TextBlockParam,
1010
} from "@anthropic-ai/sdk/resources/messages.js";
1111
import { getEnvApiKey } from "../env-api-keys.js";
12-
import { getAiTransportHost } from "../host.js";
12+
import { getAiTransportHost, resolveAiTransportHeaderSentinels } from "../host.js";
1313
import { calculateCost, clampThinkingLevel } from "../model-utils.js";
1414
import type {
1515
AnthropicMessagesCompat,
@@ -1111,7 +1111,8 @@ export const streamSimpleAnthropic: StreamFunction<
11111111
};
11121112

11131113
function isOAuthToken(apiKey: string): boolean {
1114-
return apiKey.includes("sk-ant-oat");
1114+
// Inspect the host-resolved shape only for auth routing; the SDK still receives the sentinel.
1115+
return getAiTransportHost().resolveSecretSentinel(apiKey).includes("sk-ant-oat");
11151116
}
11161117

11171118
function isAnthropicPublicEndpoint(baseUrl: string | undefined): boolean {
@@ -1161,6 +1162,7 @@ function createClient(
11611162
/^kimi(?:-|$)/.test(model.provider) && thinkingEnabled
11621163
? { sanitizeSse: false as const }
11631164
: undefined;
1165+
// Anthropic supports custom fetch, so sentinels stay opaque until guarded egress.
11641166
const fetch = getAiTransportHost().buildModelFetch(model, undefined, fetchOptions);
11651167

11661168
if (model.provider === "cloudflare-ai-gateway") {
@@ -1208,7 +1210,12 @@ function createClient(
12081210
return { client, isOAuthToken: false, serverSideFallback: false };
12091211
}
12101212

1211-
if (usesFoundryBearerAuth(model)) {
1213+
if (
1214+
usesFoundryBearerAuth({
1215+
...model,
1216+
headers: resolveAiTransportHeaderSentinels(model.headers),
1217+
})
1218+
) {
12121219
const client = new Anthropic({
12131220
apiKey: null,
12141221
authToken: apiKey,

packages/ai/src/providers/azure-openai-responses.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ function createClient(
200200
}
201201

202202
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
203+
// Both OpenAI clients support custom fetch, so sentinels stay opaque until guarded egress.
203204
const guardedFetch = getAiTransportHost().buildModelFetch({ ...model, baseUrl });
204205

205206
if (isOpenAICompatibleAzureResponsesBaseUrl(baseUrl)) {

packages/ai/src/providers/cloudflare.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Cloudflare provider metadata describes Cloudflare-hosted model capabilities.
22
import type { Model } from "../types.js";
33

4+
// This module owns URL metadata only; Anthropic/OpenAI adapters inject guarded fetch.
5+
46
export function isCloudflareProvider(provider: string): boolean {
57
return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway";
68
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { configureAiTransportHost } from "../host.js";
3+
import type { Context, Model } from "../types.js";
4+
5+
const googleMockState = vi.hoisted(() => ({ configs: [] as unknown[] }));
6+
7+
vi.mock("@google/genai", () => ({
8+
GoogleGenAI: class MockGoogleGenAI {
9+
models = {
10+
generateContentStream: vi.fn(() => {
11+
throw new Error("stop after constructor");
12+
}),
13+
};
14+
15+
constructor(config: unknown) {
16+
googleMockState.configs.push(config);
17+
}
18+
},
19+
ResourceScope: { COLLECTION: "COLLECTION" },
20+
ThinkingLevel: {
21+
THINKING_LEVEL_UNSPECIFIED: "THINKING_LEVEL_UNSPECIFIED",
22+
MINIMAL: "MINIMAL",
23+
LOW: "LOW",
24+
MEDIUM: "MEDIUM",
25+
HIGH: "HIGH",
26+
},
27+
}));
28+
29+
import { streamGoogleVertex } from "./google-vertex.js";
30+
import { streamGoogle } from "./google.js";
31+
32+
const context = {
33+
messages: [{ role: "user", content: "hello", timestamp: 0 }],
34+
} satisfies Context;
35+
const sentinel = "oc-sent-v1-0123456789abcdef01234567";
36+
37+
function googleModel(): Model<"google-generative-ai"> {
38+
return {
39+
id: "gemini-3-flash-preview",
40+
name: "Gemini 3 Flash Preview",
41+
api: "google-generative-ai",
42+
provider: "google",
43+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
44+
reasoning: true,
45+
input: ["text"],
46+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
47+
contextWindow: 1_000_000,
48+
maxTokens: 8192,
49+
};
50+
}
51+
52+
function vertexModel(): Model<"google-vertex"> {
53+
return {
54+
...googleModel(),
55+
api: "google-vertex",
56+
provider: "google-vertex",
57+
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1",
58+
};
59+
}
60+
61+
describe("Google SDK construction auth", () => {
62+
beforeEach(() => {
63+
googleMockState.configs = [];
64+
});
65+
66+
afterEach(() => {
67+
configureAiTransportHost({});
68+
});
69+
70+
it("unwraps Google API-key sentinels immediately before client construction", async () => {
71+
const buildModelFetch = vi.fn();
72+
configureAiTransportHost({
73+
buildModelFetch,
74+
resolveSecretSentinel: (value) => value.replaceAll(sentinel, "google-construction-secret"),
75+
});
76+
77+
const result = await streamGoogle(
78+
{
79+
...googleModel(),
80+
headers: { Authorization: `Bearer ${sentinel}` },
81+
},
82+
context,
83+
{ apiKey: sentinel },
84+
).result();
85+
86+
expect(result.stopReason).toBe("error");
87+
expect(googleMockState.configs[0]).toMatchObject({
88+
apiKey: "google-construction-secret",
89+
httpOptions: { headers: { Authorization: "Bearer google-construction-secret" } },
90+
});
91+
expect(JSON.stringify(googleMockState.configs[0])).not.toContain(sentinel);
92+
expect(buildModelFetch).not.toHaveBeenCalled();
93+
});
94+
95+
it("unwraps Vertex API-key sentinels immediately before client construction", async () => {
96+
const buildModelFetch = vi.fn();
97+
configureAiTransportHost({
98+
buildModelFetch,
99+
resolveSecretSentinel: (value) => value.replaceAll(sentinel, "vertex-construction-secret"),
100+
});
101+
102+
const result = await streamGoogleVertex(
103+
{
104+
...vertexModel(),
105+
headers: { "X-Provider-Token": sentinel },
106+
},
107+
context,
108+
{
109+
apiKey: sentinel,
110+
project: "demo-project",
111+
location: "us-central1",
112+
},
113+
).result();
114+
115+
expect(result.stopReason).toBe("error");
116+
expect(googleMockState.configs[0]).toMatchObject({
117+
apiKey: "vertex-construction-secret",
118+
vertexai: true,
119+
httpOptions: { headers: { "X-Provider-Token": "vertex-construction-secret" } },
120+
});
121+
expect(JSON.stringify(googleMockState.configs[0])).not.toContain(sentinel);
122+
expect(buildModelFetch).not.toHaveBeenCalled();
123+
});
124+
});

packages/ai/src/providers/google-vertex.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ResourceScope,
77
ThinkingLevel as VertexThinkingLevel,
88
} from "@google/genai";
9+
import { getAiTransportHost, resolveAiTransportHeaderSentinels } from "../host.js";
910
import type { Context, Model, SimpleStreamOptions, StreamFunction } from "../types.js";
1011
import { AssistantMessageEventStream } from "../utils/event-stream.js";
1112
import type { GoogleThinkingLevel } from "./google-shared.js";
@@ -97,9 +98,11 @@ function createClientWithApiKey(
9798
apiKey: string,
9899
optionsHeaders?: Record<string, string>,
99100
): GoogleGenAI {
101+
// @google/genai exposes RequestInit options but no custom fetch; unwrap at construction.
102+
const resolvedApiKey = getAiTransportHost().resolveSecretSentinel(apiKey);
100103
return new GoogleGenAI({
101104
vertexai: true,
102-
apiKey,
105+
apiKey: resolvedApiKey,
103106
apiVersion: API_VERSION,
104107
httpOptions: buildHttpOptions(model, optionsHeaders),
105108
});
@@ -120,7 +123,10 @@ function buildHttpOptions(
120123
}
121124

122125
if (model.headers || optionsHeaders) {
123-
httpOptions.headers = { ...model.headers, ...optionsHeaders };
126+
httpOptions.headers = resolveAiTransportHeaderSentinels({
127+
...model.headers,
128+
...optionsHeaders,
129+
});
124130
}
125131

126132
return Object.keys(httpOptions).length > 0 ? httpOptions : undefined;

0 commit comments

Comments
 (0)