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:
Michal
2026-08-20 23:33:13 +01:00
parent f8427959e5
commit 370fd0a034
22 changed files with 740 additions and 14 deletions

View File

@@ -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({

View File

@@ -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;

View File

@@ -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}`);