feat(secrets): opt-in injected secret delivery, scoped per server
Completes the path that stops mcpd writing secret VALUES into MCP server
pod specs. With `secretDelivery: injector`, the pod fetches its own
secrets from OpenBao through the agent injector, under a ServiceAccount
and role scoped to just that server's secrets — so the value never enters
etcd, and gitea-mcp cannot read the Grafana token.
Opt-in per server, defaulting to `env`. Every existing server is
bit-for-bit unchanged, and migrating is one reversible decision at a time
rather than a flag day.
The two invariants under most risk, both tested:
- **Opted-out servers produce an identical manifest.** No annotations, no
serviceAccountName, automountServiceAccountToken still false.
- **Opted-in servers still fail LOUDLY on a bad ref.** Once mcpd stops
reading a server's secrets, the check e6cd735 added no longer fires for
it, and a typo'd secretRef would degrade into a vault-agent-init
crashloop that mcpd reports as a generic pod failure — the same class of
bug that had gitea-mcp running for weeks on an empty token while
reporting healthy. `validateServerEnvRefs` resolves every ref and throws
the value away, purely to keep that error. After the value cache it is a
cache hit and costs nothing.
Shell quoting is the other silent-failure trap and is treated as part of
the contract: the agent renders `export NAME='value'` and the container
command sources it, so a value containing a space, `$`, a quote or a
newline would truncate and yield an empty token. `shellSingleQuote` is
tested by executing a real /bin/sh over nine adversarial values including
`'; export PWNED=1; '` — and those tests fail against naive quoting,
confirmed before keeping them.
`sh -c <script> arg0 arg1 …` preserves argv via $0/$@, and `exec` keeps
PID 1 as the real process, which matters because mcpd attaches to PID 1's
stdin/stdout for STDIO servers.
Docker/Podman declare `capabilities.secretRefs: false` and fall back to
inline resolution, so local development is untouched. Deleting a server
revokes its identity, after its pods are gone and best-effort — a role no
pod can authenticate as grants nothing, and failing the delete over it
would strand the row.
Per the CLI rules, `secretDelivery`/`entrypoint` are `create` flags,
round-trip through apply -f, and show in `describe server` — which now
also flags servers still inlining secrets into their pod spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
This commit is contained in:
@@ -44,6 +44,8 @@ const ServerSpecSchema = z.object({
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: z.enum(['env', 'injector']).optional(),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const SecretSpecSchema = z.object({
|
||||
@@ -136,6 +138,8 @@ const TemplateSpecSchema = z.object({
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: z.enum(['env', 'injector']).optional(),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const UserSpecSchema = z.object({
|
||||
|
||||
@@ -252,6 +252,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--health-check-interval <seconds>', 'Readiness probe interval in seconds (default 60)')
|
||||
.option('--health-check-timeout <seconds>', 'Readiness probe timeout in seconds (default 10)')
|
||||
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
|
||||
.option('--secret-delivery <mode>', 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)')
|
||||
.option('--entrypoint <argv>', 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect')
|
||||
.option('--from-template <name>', 'Create from template (name or name:version)')
|
||||
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
|
||||
.option('--force', 'Update if already exists')
|
||||
@@ -327,6 +329,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
if (opts.description !== undefined) body.description = opts.description;
|
||||
if (opts.transport) body.transport = opts.transport;
|
||||
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
|
||||
if (opts.secretDelivery) body.secretDelivery = opts.secretDelivery;
|
||||
if (opts.entrypoint) body.entrypoint = (opts.entrypoint as string).split(',').map((a) => a.trim()).filter(Boolean);
|
||||
if (opts.packageName) body.packageName = opts.packageName;
|
||||
if (opts.runtime) body.runtime = opts.runtime;
|
||||
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
|
||||
|
||||
@@ -21,6 +21,20 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
lines.push(`${pad('Name:')}${server.name}`);
|
||||
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
|
||||
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
|
||||
{
|
||||
// Surface the remaining exposure rather than leaving it silent: a server
|
||||
// still on `env` with secret refs has those VALUES written into its pod
|
||||
// spec, i.e. cleartext in etcd for anyone with `get pod`.
|
||||
const delivery = (server as { secretDelivery?: string }).secretDelivery ?? 'env';
|
||||
const env = (server.env ?? []) as Array<{ valueFrom?: unknown }>;
|
||||
const hasSecretRefs = env.some((e) => e.valueFrom !== undefined);
|
||||
const note = delivery === 'injector'
|
||||
? ' (pod fetches its own secrets under a scoped OpenBao identity)'
|
||||
: hasSecretRefs
|
||||
? ' ⚠ secret values are inlined into the pod spec'
|
||||
: '';
|
||||
lines.push(`${pad('Secret Delivery:')}${delivery}${note}`);
|
||||
}
|
||||
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
|
||||
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
|
||||
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
|
||||
|
||||
Reference in New Issue
Block a user