Skip to content

Commit 73c575e

Browse files
committed
fix(vault): scope credential injection policies to bailian
1 parent 3c5727d commit 73c575e

9 files changed

Lines changed: 228 additions & 44 deletions

File tree

docs/guides/use-mcp-and-vaults.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ vaults:
2424
secret_value: ${DB_TOKEN}
2525
networking:
2626
type: limited
27-
allowed_hosts: ["api.example.com"]
2827
```
2928
3029
| Credential type | Key fields |
@@ -62,6 +61,11 @@ policy or with `type: unrestricted` retain that scope; an omitted injection loca
6261
defaults to headers enabled and body disabled. Legacy `type: limited` requires an
6362
explicit `allowed_hosts` list.
6463

64+
Credential `networking.allowed_hosts` and `injection_location` are Bailian-only.
65+
For other providers, keep using `networking.type` (`unrestricted` or `limited`);
66+
it is required when `networking` is present. Pin the vault with `provider: bailian`
67+
when using these new fields in a multi-provider project.
68+
6569
## Attach MCP servers to an agent
6670

6771
Reference a URL-based MCP server and bind the vault that holds its token:

docs/reference/configuration.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ vaults:
222222
| `type` | `"environment_variable"` | yes | |
223223
| `secret_name` | string | yes | Secret name. |
224224
| `secret_value` | string | yes | Secret value (string or number, coerced). |
225-
| `networking.type` | `"unrestricted"` \| `"limited"` | no | Legacy policy type. Bailian requests use `allowed_hosts` instead. |
225+
| `networking.type` | `"unrestricted"` \| `"limited"` | conditional | Required when `networking` is present for non-Bailian providers. Bailian requests use `allowed_hosts` instead. |
226226
| `networking.allowed_hosts` | string[] | no | Bailian credential injection host allow-list, e.g. `["api.example.com", "*.example.org"]`; `["*"]` allows all hosts. |
227227
| `injection_location.header` | boolean | no | Bailian: allow secret replacement in request headers. |
228228
| `injection_location.body` | boolean | no | Bailian: allow secret replacement in request bodies. |
@@ -232,9 +232,14 @@ When omitted, creation uses `networking: { allowed_hosts: ["*"] }` and
232232
`injection_location: { header: true, body: false }`. Legacy `networking.type: unrestricted`
233233
maps to `["*"]`; `limited` must include `allowed_hosts` and is never widened implicitly.
234234
Explicit host lists and injection booleans are preserved, including through sync/export.
235-
Credential retry adoption compares host sets and injection policy as well as the existing
235+
Bailian credential retry adoption compares host sets and injection policy as well as the existing
236236
identity and metadata fields; a different policy is not treated as an exact match.
237237

238+
Credential `networking.allowed_hosts` and `injection_location` are rejected for
239+
non-Bailian providers, whose legacy networking and retry-matching behavior is unchanged.
240+
In multi-provider projects, pin such vaults with `provider: bailian` (or select
241+
Bailian via `defaults.provider`) to avoid applying these fields to other providers.
242+
238243
## Memory store
239244

240245
```yaml

packages/sdk/src/internal/core/validate-config.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { Diagnostic } from "../types/plan.ts";
1111
import type { ResourceAddress } from "../types/state.ts";
1212
import { providerMountPrefix, resolveSandboxMountPath } from "../utils/sandbox-mount.ts";
1313
import { findMissingBailianMcpToolConfigs } from "../validation/bailian.ts";
14+
import { validateCredentialPolicy } from "../validation/vault-credential.ts";
1415
import { resolveAgentMaterialization } from "./agent-materialization.ts";
1516

1617
export interface ValidateProjectConfigOptions {
@@ -159,15 +160,14 @@ export function collectProviderCapabilities(
159160

160161
for (const [name, vault] of Object.entries(config.vaults ?? {})) {
161162
if (vault.provider && vault.provider !== providerName) continue;
162-
if (
163-
providerName !== "bailian" &&
164-
vault.credentials.some((credential) => credential.injection_location !== undefined)
165-
) {
166-
diagnostics.error(
167-
`${providerName}.vault.injection_location.unsupported`,
168-
`vault.${name}: provider '${providerName}' does not support credential injection_location; pin this vault to bailian.`,
169-
{ type: "vault", name, provider: providerName },
170-
);
163+
for (const credential of vault.credentials) {
164+
for (const issue of validateCredentialPolicy(providerName, credential)) {
165+
diagnostics.error(
166+
`${providerName}.vault.${issue.code}`,
167+
`vault.${name} credential '${credential.name}': ${issue.message}`,
168+
{ type: "vault", name, provider: providerName },
169+
);
170+
}
171171
}
172172
}
173173

packages/sdk/src/internal/core/vault-credential-runtime.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ProviderAdapter } from "../providers/interface.ts";
44
import type { CredentialDecl, ResolvedProjectConfig } from "../types/config.ts";
55
import type { VaultCredentialInfo } from "../types/managed-api.ts";
66
import type { ResourceAddress, ResourceState } from "../types/state.ts";
7+
import { validateCredentialPolicy } from "../validation/vault-credential.ts";
78
import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts";
89
import { getRuntimeProvider, readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts";
910
import { planProjectContext } from "./resource-runtime.ts";
@@ -131,6 +132,8 @@ async function prepareVaultCredentialCreate(
131132
if (vault.credentials.slice(0, -1).some((entry) => entry.name === credentialName)) {
132133
throw new UserError(`Vault '${vaultName}' already declares a credential named '${credentialName}'.`);
133134
}
135+
const policyIssue = validateCredentialPolicy(provider, credential)[0];
136+
if (policyIssue) throw new UserError(policyIssue.message);
134137
if (provider === "bailian" && credential.type !== "environment_variable") {
135138
throw new UserError(
136139
`Credential '${credentialName}' uses '${credential.type}', but Bailian only supports 'environment_variable' credentials.`,
@@ -179,7 +182,7 @@ async function prepareVaultCredentialCreate(
179182
(remoteCredential) => remoteCredential.display_name === credentialName,
180183
)
181184
: [];
182-
const exact = sameName.find((remoteCredential) => credentialMatches(remoteCredential, credential));
185+
const exact = sameName.find((remoteCredential) => credentialMatches(provider, remoteCredential, credential));
183186
if (sameName.length > 0 && !exact) {
184187
throw new UserError(`Vault '${vaultName}' already has a different remote credential named '${credentialName}'.`);
185188
}
@@ -203,9 +206,15 @@ function resolveVaultProvider(
203206
throw new UserError("Cannot infer one provider for Vault Credential create.");
204207
}
205208

206-
function credentialMatches(remote: VaultCredentialInfo, desired: CredentialDecl): boolean {
209+
function credentialMatches(provider: string, remote: VaultCredentialInfo, desired: CredentialDecl): boolean {
207210
if (remote.auth_type !== desired.type || !metadataMatches(remote.metadata, desired.metadata)) return false;
208211
if (desired.type === "static_bearer") return remote.mcp_server_url === desired.mcp_server_url;
212+
if (provider !== "bailian") {
213+
return (
214+
remote.secret_name === desired.secret_name &&
215+
(remote.networking_type ?? "unrestricted") === (desired.networking?.type ?? "unrestricted")
216+
);
217+
}
209218
const desiredHosts = credentialHosts(desired.networking);
210219
const remoteHosts = credentialHosts(remote.networking ?? { type: remote.networking_type });
211220
const desiredLocation = desired.injection_location;

packages/sdk/src/internal/parser/schema.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod";
22
import { canonicalToolName } from "../utils/tool-permissions.ts";
3+
import { validateCredentialPolicy } from "../validation/vault-credential.ts";
34

45
const networkingSchema = z.object({
56
type: z.enum(["unrestricted", "limited"]),
@@ -366,25 +367,46 @@ const deploymentSchema = z.object({
366367
environment_variables: z.string().optional(),
367368
});
368369

369-
export const projectConfigSchema = z.object({
370-
version: z.string(),
371-
providers: z.record(z.string(), z.unknown()),
372-
defaults: z
373-
.object({
374-
provider: z.string().optional(),
375-
identity: z.string().min(1).optional(),
376-
})
377-
.optional(),
378-
environments: z.record(z.string(), environmentSchema).optional(),
379-
tunnels: z.record(z.string(), tunnelSchema).optional(),
380-
vaults: z.record(z.string(), vaultSchema).optional(),
381-
memory_stores: z.record(z.string(), memoryStoreSchema).optional(),
382-
skills: z.record(z.string(), skillSchema).optional(),
383-
files: z.record(z.string(), fileSchema).optional(),
384-
identities: z.record(z.string(), identitySchema).optional(),
385-
agents: z.record(z.string(), agentSchema).optional(),
386-
channels: z.record(z.string(), channelSchema).optional(),
387-
deployments: z.record(z.string(), deploymentSchema).optional(),
388-
});
370+
export const projectConfigSchema = z
371+
.object({
372+
version: z.string(),
373+
providers: z.record(z.string(), z.unknown()),
374+
defaults: z
375+
.object({
376+
provider: z.string().optional(),
377+
identity: z.string().min(1).optional(),
378+
})
379+
.optional(),
380+
environments: z.record(z.string(), environmentSchema).optional(),
381+
tunnels: z.record(z.string(), tunnelSchema).optional(),
382+
vaults: z.record(z.string(), vaultSchema).optional(),
383+
memory_stores: z.record(z.string(), memoryStoreSchema).optional(),
384+
skills: z.record(z.string(), skillSchema).optional(),
385+
files: z.record(z.string(), fileSchema).optional(),
386+
identities: z.record(z.string(), identitySchema).optional(),
387+
agents: z.record(z.string(), agentSchema).optional(),
388+
channels: z.record(z.string(), channelSchema).optional(),
389+
deployments: z.record(z.string(), deploymentSchema).optional(),
390+
})
391+
.superRefine((config, context) => {
392+
const defaultProvider = config.defaults?.provider;
393+
const targetProviders =
394+
defaultProvider && defaultProvider !== "all" ? [defaultProvider] : Object.keys(config.providers);
395+
for (const [vaultName, vault] of Object.entries(config.vaults ?? {})) {
396+
const providers = vault.provider ? [vault.provider] : targetProviders;
397+
// Only a positively identified Bailian target may omit type or use injection policy fields.
398+
for (const provider of providers.length > 0 ? providers : ["unknown"]) {
399+
for (const [index, credential] of vault.credentials.entries()) {
400+
for (const issue of validateCredentialPolicy(provider, credential)) {
401+
context.addIssue({
402+
code: "custom",
403+
path: ["vaults", vaultName, "credentials", index, ...issue.field.split(".")],
404+
message: issue.message,
405+
});
406+
}
407+
}
408+
}
409+
}
410+
});
389411

390412
export type ParsedConfig = z.infer<typeof projectConfigSchema>;

packages/sdk/src/internal/types/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ export interface VaultDecl {
101101
export type CredentialType = "static_bearer" | "environment_variable";
102102

103103
export interface CredentialNetworking {
104-
/** Legacy policy type; Bailian requests use allowed_hosts instead. */
104+
/** Required when networking is declared for non-Bailian providers; optional for Bailian. */
105105
type?: "unrestricted" | "limited";
106+
/** Bailian only. Other providers retain their networking.type contract. */
106107
allowed_hosts?: string[];
107108
}
108109

@@ -123,6 +124,7 @@ export interface CredentialDecl {
123124
secret_name?: string;
124125
secret_value?: string;
125126
networking?: CredentialNetworking;
127+
/** Bailian only. */
126128
injection_location?: CredentialInjectionLocation;
127129
}
128130

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CredentialDecl } from "../types/config.ts";
2+
3+
interface CredentialPolicyIssue {
4+
field: string;
5+
code: string;
6+
message: string;
7+
}
8+
9+
/** Keep Bailian's injection policy separate from other providers' legacy networking contract. */
10+
export function validateCredentialPolicy(provider: string, credential: CredentialDecl): CredentialPolicyIssue[] {
11+
if (provider === "bailian") return [];
12+
const issues: CredentialPolicyIssue[] = [];
13+
for (const [field, value] of [
14+
["networking.allowed_hosts", credential.networking?.allowed_hosts],
15+
["injection_location", credential.injection_location],
16+
] as const) {
17+
if (value !== undefined) {
18+
issues.push({
19+
field,
20+
code: `${field}.unsupported`,
21+
message: `Credential ${field} is only supported by bailian; remove it or pin this vault to bailian.`,
22+
});
23+
}
24+
}
25+
if (
26+
credential.type === "environment_variable" &&
27+
credential.networking !== undefined &&
28+
credential.networking.type === undefined
29+
) {
30+
issues.push({
31+
field: "networking.type",
32+
code: "networking.type.required",
33+
message: `Credential networking.type is required for provider '${provider}' when networking is declared.`,
34+
});
35+
}
36+
return issues;
37+
}

packages/sdk/tests/unit/bailian-credential-policy.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,71 @@ describe("Bailian credential injection policy", () => {
103103
});
104104
expect(diagnostics).toEqual([]);
105105
});
106+
107+
test.each(["claude", "qoder", "ark"])("keeps legacy networking validation for %s", (provider) => {
108+
for (const networking of [undefined, { type: "unrestricted" as const }, { type: "limited" as const }]) {
109+
const legacy = { ...credential, networking, injection_location: undefined };
110+
const config = {
111+
version: "1",
112+
providers: { [provider]: {} },
113+
vaults: { secrets: { display_name: "Secrets", credentials: [legacy] } },
114+
};
115+
expect(projectConfigSchema.parse(config).vaults?.secrets?.credentials[0]).toEqual(legacy);
116+
expect(validateProjectConfig(config)).toEqual([]);
117+
}
118+
const missingType = {
119+
version: "1",
120+
providers: { [provider]: {} },
121+
vaults: {
122+
secrets: {
123+
display_name: "Secrets",
124+
credentials: [{ ...credential, networking: {}, injection_location: undefined }],
125+
},
126+
},
127+
};
128+
expect(projectConfigSchema.safeParse(missingType).success).toBe(false);
129+
expect(validateProjectConfig(missingType).map((diagnostic) => diagnostic.code)).toContain(
130+
`${provider}.vault.networking.type.required`,
131+
);
132+
});
133+
134+
test.each(["claude", "qoder", "ark"])("blocks Bailian policy fields for %s before execution", (provider) => {
135+
const config = {
136+
version: "1",
137+
providers: { [provider]: {} },
138+
vaults: { secrets: { display_name: "Secrets", credentials: [credential] } },
139+
};
140+
const parsed = projectConfigSchema.safeParse(config);
141+
expect(parsed.success).toBe(false);
142+
if (!parsed.success) {
143+
expect(parsed.error.issues.map((issue) => issue.path.join("."))).toContain(
144+
"vaults.secrets.credentials.0.networking.allowed_hosts",
145+
);
146+
expect(JSON.stringify(parsed.error.issues)).not.toContain(credential.secret_value!);
147+
}
148+
expect(validateProjectConfig(config).map((diagnostic) => diagnostic.code)).toContain(
149+
`${provider}.vault.networking.allowed_hosts.unsupported`,
150+
);
151+
});
152+
153+
test("resolves policy ownership from the vault, defaults, and provider selection", () => {
154+
const config = {
155+
version: "1",
156+
providers: { bailian: {}, qoder: {} },
157+
vaults: { secrets: { display_name: "Secrets", credentials: [credential] } },
158+
};
159+
expect(projectConfigSchema.safeParse(config).success).toBe(false);
160+
const bailianDefault = { ...config, defaults: { provider: "bailian" } };
161+
expect(projectConfigSchema.safeParse(bailianDefault).success).toBe(true);
162+
expect(
163+
validateProjectConfig(bailianDefault, { providers: ["qoder"] }).map((diagnostic) => diagnostic.code),
164+
).toContain("qoder.vault.networking.allowed_hosts.unsupported");
165+
expect(
166+
projectConfigSchema.safeParse({
167+
...config,
168+
defaults: { provider: "qoder" },
169+
vaults: { secrets: { ...config.vaults.secrets, provider: "bailian" } },
170+
}).success,
171+
).toBe(true);
172+
});
106173
});

0 commit comments

Comments
 (0)