From 9f3e1c08a311c1b328702ad23204df9bea505de1 Mon Sep 17 00:00:00 2001 From: Michael Hess Date: Mon, 30 Mar 2026 19:35:54 +0000 Subject: [PATCH 1/3] Add secretEnv config for secure custom environment variable injection Allow users to declare custom secret environment variable names in project config (paude.json or devcontainer.json). Values are read from the host's os.environ and injected securely via exec -e (Podman) or tmpfs (OpenShift), never appearing in the container spec. Config format: - paude.json: top-level "secretEnv": ["MY_TOKEN"] - devcontainer.json: "customizations.paude.secretEnv": ["MY_TOKEN"] Secret env var names are persisted as container labels (Podman) or StatefulSet annotations (OpenShift) so they survive across connect/start/upgrade operations. Co-Authored-By: Claude Opus 4.6 --- src/paude/backends/base.py | 1 + src/paude/backends/openshift/resources.py | 18 ++++++ .../backends/openshift/session_connection.py | 11 ++++ .../backends/openshift/session_lifecycle.py | 6 ++ src/paude/backends/podman/backend.py | 49 +++++++++++++--- src/paude/backends/shared.py | 40 +++++++++++++ src/paude/cli/create.py | 20 ++++--- src/paude/cli/create_openshift.py | 2 + src/paude/cli/create_podman.py | 2 + src/paude/cli/helpers.py | 18 +++++- src/paude/cli/upgrade.py | 57 ++++++++++++++++--- src/paude/config/models.py | 4 ++ src/paude/config/parser.py | 17 +++++- tests/test_upgrade.py | 15 ++--- 14 files changed, 226 insertions(+), 34 deletions(-) diff --git a/src/paude/backends/base.py b/src/paude/backends/base.py index 18283b7..f50f2db 100644 --- a/src/paude/backends/base.py +++ b/src/paude/backends/base.py @@ -89,6 +89,7 @@ class SessionConfig: ports: list[tuple[int, int]] = field(default_factory=list) otel_ports: list[int] = field(default_factory=list) otel_endpoint: str | None = None + secret_env_names: list[str] = field(default_factory=list) class Backend(Protocol): diff --git a/src/paude/backends/openshift/resources.py b/src/paude/backends/openshift/resources.py index 8c1d6f7..db5ac38 100644 --- a/src/paude/backends/openshift/resources.py +++ b/src/paude/backends/openshift/resources.py @@ -12,6 +12,7 @@ PAUDE_LABEL_AGENT, PAUDE_LABEL_GPU, PAUDE_LABEL_PROVIDER, + PAUDE_LABEL_SECRET_ENV, PAUDE_LABEL_VERSION, PAUDE_LABEL_YOLO, encode_path, @@ -82,6 +83,7 @@ def __init__( self._otel_endpoint: str | None = None self._env: dict[str, str] = {} self._workspace: Path | None = None + self._secret_env_names: list[str] = [] self._pvc_size = "10Gi" self._storage_class: str | None = None @@ -121,6 +123,18 @@ def with_workspace(self, workspace: Path) -> StatefulSetBuilder: self._workspace = workspace return self + def with_secret_env_names(self, names: list[str]) -> StatefulSetBuilder: + """Set custom secret env var names. + + Args: + names: List of env var names to inject securely. + + Returns: + Self for method chaining. + """ + self._secret_env_names = names + return self + def with_pvc( self, size: str = "10Gi", @@ -170,6 +184,10 @@ def _build_metadata(self, created_at: str) -> dict[str, Any]: metadata["annotations"]["paude.io/workspace"] = encoded if self._otel_endpoint: metadata["annotations"]["paude.io/otel-endpoint"] = self._otel_endpoint + if self._secret_env_names: + metadata["annotations"][PAUDE_LABEL_SECRET_ENV] = ",".join( + self._secret_env_names + ) return metadata def _build_volumes(self) -> list[dict[str, Any]]: diff --git a/src/paude/backends/openshift/session_connection.py b/src/paude/backends/openshift/session_connection.py index 945cf5d..7d9b5ac 100644 --- a/src/paude/backends/openshift/session_connection.py +++ b/src/paude/backends/openshift/session_connection.py @@ -264,6 +264,11 @@ def _sync_for_connect( """Sync credentials/config for a connect operation.""" from paude.agents import get_agent from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import ( + PAUDE_LABEL_SECRET_ENV, + build_custom_secret_env, + parse_secret_env_label, + ) sts = self._lookup.get_statefulset(name) agent_name = self._agent_name_from_sts(sts) @@ -273,6 +278,12 @@ def _sync_for_connect( agent = get_agent(agent_name, provider=provider) secret_env = build_secret_environment_from_config(agent.config) + # Merge custom secret env vars from STS annotations + annotations = (sts or {}).get("metadata", {}).get("annotations", {}) + custom_names = parse_secret_env_label(annotations.get(PAUDE_LABEL_SECRET_ENV)) + if custom_names: + secret_env.update(build_custom_secret_env(custom_names)) + if self._syncer.is_config_synced(pname): self._syncer.sync_credentials( pname, diff --git a/src/paude/backends/openshift/session_lifecycle.py b/src/paude/backends/openshift/session_lifecycle.py index 6fd13b9..a19e97e 100644 --- a/src/paude/backends/openshift/session_lifecycle.py +++ b/src/paude/backends/openshift/session_lifecycle.py @@ -131,9 +131,12 @@ def _build_session_env( """ from paude.agents import get_agent from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import build_custom_secret_env agent = get_agent(config.agent, provider=config.provider) secret_env = build_secret_environment_from_config(agent.config) + if config.secret_env_names: + secret_env.update(build_custom_secret_env(config.secret_env_names)) proxy_name = ( proxy_resource_name(session_name) if config.allowed_domains is not None @@ -171,6 +174,7 @@ def _apply_and_wait( gpu=config.gpu, yolo=config.yolo, otel_endpoint=config.otel_endpoint, + secret_env_names=config.secret_env_names, ) print( @@ -396,6 +400,7 @@ def _generate_statefulset_spec( gpu: str | None = None, yolo: bool = False, otel_endpoint: str | None = None, + secret_env_names: list[str] | None = None, ) -> dict[str, Any]: """Generate a Kubernetes StatefulSet specification.""" return ( @@ -413,5 +418,6 @@ def _generate_statefulset_spec( .with_workspace(workspace) .with_pvc(size=pvc_size, storage_class=storage_class) .with_otel_endpoint(otel_endpoint) + .with_secret_env_names(secret_env_names or []) .build() ) diff --git a/src/paude/backends/podman/backend.py b/src/paude/backends/podman/backend.py index f8267d6..6a3f54b 100644 --- a/src/paude/backends/podman/backend.py +++ b/src/paude/backends/podman/backend.py @@ -36,6 +36,7 @@ PAUDE_LABEL_OTEL_PORTS, PAUDE_LABEL_PROVIDER, PAUDE_LABEL_PROXY_IMAGE, + PAUDE_LABEL_SECRET_ENV, PAUDE_LABEL_SESSION, PAUDE_LABEL_VERSION, PAUDE_LABEL_WORKSPACE, @@ -123,6 +124,13 @@ def _get_session_agent(self, session_name: str) -> Agent: provider = labels.get(PAUDE_LABEL_PROVIDER) or None return get_agent(agent_name, provider=provider) + def _get_secret_env_names(self, session_name: str) -> list[str]: + """Get custom secret env var names from session labels.""" + from paude.backends.shared import parse_secret_env_label + + labels = self._get_session_labels(session_name) + return parse_secret_env_label(labels.get(PAUDE_LABEL_SECRET_ENV)) + def _get_port_urls(self, agent: Agent) -> list[str]: """Get port-forward URL strings for an agent.""" return [f"http://localhost:{hp}" for hp, _cp in agent.config.exposed_ports] @@ -162,10 +170,14 @@ def _start_port_forward(self, session_name: str, agent: Agent) -> None: self._port_forward.start(session_name, cname, ports) def _build_attach_env( - self, agent: Agent, github_token: str | None + self, + agent: Agent, + github_token: str | None, + secret_env_names: list[str] | None = None, ) -> dict[str, str] | None: """Build extra environment for container attachment.""" from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import build_custom_secret_env secret_env = build_secret_environment_from_config(agent.config) @@ -174,6 +186,9 @@ def _build_attach_env( extra_env["GH_TOKEN"] = github_token extra_env.update(secret_env) + if secret_env_names: + extra_env.update(build_custom_secret_env(secret_env_names)) + port_urls = self._get_port_urls(agent) if port_urls: extra_env["PAUDE_PORT_URLS"] = ";".join(port_urls) @@ -301,6 +316,8 @@ def create_session(self, config: SessionConfig) -> Session: ) if config.otel_endpoint: labels[PAUDE_LABEL_OTEL_ENDPOINT] = config.otel_endpoint + if config.secret_env_names: + labels[PAUDE_LABEL_SECRET_ENV] = ",".join(config.secret_env_names) print(f"Creating session '{session_name}'...", file=sys.stderr) @@ -433,19 +450,31 @@ def start_session_no_attach( agent = self._get_session_agent(name) self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) - self._start_agent_headless_in_container(cname, agent, github_token) + secret_names = self._get_secret_env_names(name) + self._start_agent_headless_in_container( + cname, agent, github_token, secret_env_names=secret_names + ) def start_agent_headless(self, name: str, github_token: str | None = None) -> None: """Start the agent in headless mode inside the container.""" cname = self._require_running_session(name) agent = self._get_session_agent(name) - self._start_agent_headless_in_container(cname, agent, github_token) + secret_names = self._get_secret_env_names(name) + self._start_agent_headless_in_container( + cname, agent, github_token, secret_env_names=secret_names + ) def _start_agent_headless_in_container( - self, cname: str, agent: Agent, github_token: str | None = None + self, + cname: str, + agent: Agent, + github_token: str | None = None, + secret_env_names: list[str] | None = None, ) -> None: """Start the agent in headless mode (internal, skips session lookup).""" - env_vars = self._build_attach_env(agent, github_token=github_token) + env_vars = self._build_attach_env( + agent, github_token=github_token, secret_env_names=secret_env_names + ) cmd: list[str] = ["env", "PAUDE_HEADLESS=1"] if env_vars: for key, value in env_vars.items(): @@ -528,12 +557,15 @@ def start_session(self, name: str, github_token: str | None = None) -> int: self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) + secret_names = self._get_secret_env_names(name) self._start_port_forward(name, agent) self._print_port_urls(name, agent) exit_code = self._runner.attach_container( cname, entrypoint=CONTAINER_ENTRYPOINT, - extra_env=self._build_attach_env(agent, github_token), + extra_env=self._build_attach_env( + agent, github_token, secret_env_names=secret_names + ), ) self._print_port_urls(name, agent) return exit_code @@ -595,6 +627,7 @@ def connect_session(self, name: str, github_token: str | None = None) -> int: self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) + secret_names = self._get_secret_env_names(name) self._start_port_forward(name, agent) print(f"Connecting to session '{name}'...", file=sys.stderr) self._print_port_urls(name, agent) @@ -602,7 +635,9 @@ def connect_session(self, name: str, github_token: str | None = None) -> int: exit_code = self._runner.attach_container( cname, entrypoint=CONTAINER_ENTRYPOINT, - extra_env=self._build_attach_env(agent, github_token), + extra_env=self._build_attach_env( + agent, github_token, secret_env_names=secret_names + ), ) finally: self._port_forward.stop(name) diff --git a/src/paude/backends/shared.py b/src/paude/backends/shared.py index 20e79a1..5ead878 100644 --- a/src/paude/backends/shared.py +++ b/src/paude/backends/shared.py @@ -3,6 +3,8 @@ from __future__ import annotations import base64 +import os +import sys from pathlib import Path from typing import TYPE_CHECKING @@ -25,6 +27,7 @@ PAUDE_LABEL_PROVIDER = "paude.io/provider" PAUDE_LABEL_OTEL_PORTS = "paude.io/otel-ports" PAUDE_LABEL_OTEL_ENDPOINT = "paude.io/otel-endpoint" +PAUDE_LABEL_SECRET_ENV = "paude.io/secret-env" # noqa: S105 - label key, not a password SQUID_BLOCKED_LOG_PATH = "/tmp/squid-blocked.log" # noqa: S108 @@ -221,6 +224,43 @@ def generate_sandbox_config_script( return agent.apply_sandbox_config(CONTAINER_HOME, workspace, args) +def build_custom_secret_env(secret_env_names: list[str]) -> dict[str, str]: + """Build secret env dict from a list of var names, reading values from os.environ. + + Args: + secret_env_names: List of environment variable names to look up. + + Returns: + Dictionary of var_name -> value for vars present in os.environ. + """ + env: dict[str, str] = {} + for var_name in secret_env_names: + value = os.environ.get(var_name) + if value is not None: + env[var_name] = value + else: + print( + f"Warning: secretEnv variable '{var_name}' " + "not found in host environment", + file=sys.stderr, + ) + return env + + +def parse_secret_env_label(label_value: str | None) -> list[str]: + """Parse the secret-env label value into a list of env var names. + + Args: + label_value: Comma-separated env var names, or None. + + Returns: + List of env var names. + """ + if not label_value: + return [] + return [n.strip() for n in label_value.split(",") if n.strip()] + + def build_ssh_backend( entry: object, connect_timeout: int | None = None, diff --git a/src/paude/cli/create.py b/src/paude/cli/create.py index 9d31a63..ac8867a 100644 --- a/src/paude/cli/create.py +++ b/src/paude/cli/create.py @@ -322,14 +322,16 @@ def session_create( raise typer.Exit(1) from None # Shared pre-create: parse args, build env, expand domains, show warnings - expanded_domains, parsed_args, env, unrestricted = _prepare_session_create( - allowed_domains=r_allowed_domains, - yolo=r_yolo, - claude_args=claude_args, - config_obj=config, - agent_name=r_agent, - provider_name=r_provider, - otel_endpoint=r_otel_endpoint, + expanded_domains, parsed_args, env, unrestricted, secret_env_names = ( + _prepare_session_create( + allowed_domains=r_allowed_domains, + yolo=r_yolo, + claude_args=claude_args, + config_obj=config, + agent_name=r_agent, + provider_name=r_provider, + otel_endpoint=r_otel_endpoint, + ) ) # Compute OTEL proxy ports (non-standard ports to open in squid) @@ -364,6 +366,7 @@ def session_create( gpu=r_gpu, otel_ports=otel_ports, otel_endpoint=r_otel_endpoint, + secret_env_names=secret_env_names, ) else: from paude.cli.create_openshift import create_openshift_session @@ -392,4 +395,5 @@ def session_create( build_resources=r_openshift_build_resources, otel_ports=otel_ports, otel_endpoint=r_otel_endpoint, + secret_env_names=secret_env_names, ) diff --git a/src/paude/cli/create_openshift.py b/src/paude/cli/create_openshift.py index 41474eb..3c99dc6 100644 --- a/src/paude/cli/create_openshift.py +++ b/src/paude/cli/create_openshift.py @@ -50,6 +50,7 @@ def create_openshift_session( build_resources: dict[str, dict[str, str]] | None = None, otel_ports: list[int] | None = None, otel_endpoint: str | None = None, + secret_env_names: list[str] | None = None, ) -> None: """OpenShift-specific session creation logic.""" os_script_dir = _detect_dev_script_dir() @@ -123,6 +124,7 @@ def create_openshift_session( ports=agent.config.exposed_ports, otel_ports=otel_ports or [], otel_endpoint=otel_endpoint, + secret_env_names=secret_env_names or [], ) session = os_backend.create_session(session_config) diff --git a/src/paude/cli/create_podman.py b/src/paude/cli/create_podman.py index 93e89b3..c2feb87 100644 --- a/src/paude/cli/create_podman.py +++ b/src/paude/cli/create_podman.py @@ -45,6 +45,7 @@ def create_podman_session( gpu: str | None = None, otel_ports: list[int] | None = None, otel_endpoint: str | None = None, + secret_env_names: list[str] | None = None, ) -> None: """Local container session creation logic (Podman or Docker).""" from paude.container import ImageManager @@ -115,6 +116,7 @@ def create_podman_session( ports=agent_instance.config.exposed_ports, otel_ports=otel_ports or [], otel_endpoint=otel_endpoint, + secret_env_names=secret_env_names or [], ) try: diff --git a/src/paude/cli/helpers.py b/src/paude/cli/helpers.py index 4e74b46..8f38721 100644 --- a/src/paude/cli/helpers.py +++ b/src/paude/cli/helpers.py @@ -265,11 +265,11 @@ def _prepare_session_create( agent_name: str = "claude", provider_name: str | None = None, otel_endpoint: str | None = None, -) -> tuple[list[str] | None, list[str], dict[str, str], bool]: +) -> tuple[list[str] | None, list[str], dict[str, str], bool, list[str]]: """Shared pre-create logic for both backends. Returns: - Tuple of (expanded_domains, parsed_args, env, unrestricted). + Tuple of (expanded_domains, parsed_args, env, unrestricted, secret_env_names). """ from paude.agents import get_agent from paude.domains import is_unrestricted @@ -298,6 +298,18 @@ def _prepare_session_create( unrestricted = is_unrestricted(expanded_domains) + # Extract secret env var names from config + secret_env_names: list[str] = [] + if config_obj and config_obj.container_secret_env: + invalid = [n for n in config_obj.container_secret_env if "," in n] + if invalid: + typer.echo( + f"Error: secretEnv names cannot contain commas: {invalid}", + err=True, + ) + raise typer.Exit(1) + secret_env_names = list(config_obj.container_secret_env) + # Show warnings for dangerous configurations if yolo and unrestricted: typer.echo( @@ -310,7 +322,7 @@ def _prepare_session_create( ) typer.echo("", err=True) - return expanded_domains, parsed_args, env, unrestricted + return expanded_domains, parsed_args, env, unrestricted, secret_env_names def _finalize_session_create( diff --git a/src/paude/cli/upgrade.py b/src/paude/cli/upgrade.py index cde8eb7..9118600 100644 --- a/src/paude/cli/upgrade.py +++ b/src/paude/cli/upgrade.py @@ -352,13 +352,15 @@ def _upgrade_podman( # allowed_domains=None means no proxy (unrestricted); # passing None to _prepare_session_create would incorrectly add defaults. if allowed_domains is not None: - expanded_domains, parsed_args, _env, unrestricted = _prepare_session_create( - allowed_domains, - yolo, - None, - config, - agent_name=agent_name, - otel_endpoint=otel_endpoint, + expanded_domains, parsed_args, _env, unrestricted, _secret_names = ( + _prepare_session_create( + allowed_domains, + yolo, + None, + config, + agent_name=agent_name, + otel_endpoint=otel_endpoint, + ) ) session_domains = expanded_domains if not unrestricted else allowed_domains else: @@ -376,6 +378,18 @@ def _upgrade_podman( otel_ports = otel_proxy_ports(otel_endpoint) + # Extract secret env names from config + secret_env_names: list[str] = [] + if config and config.container_secret_env: + invalid = [n for n in config.container_secret_env if "," in n] + if invalid: + typer.echo( + f"Error: secretEnv names cannot contain commas: {invalid}", + err=True, + ) + raise typer.Exit(1) + secret_env_names = list(config.container_secret_env) + # Create new session config with reuse_volume=True from paude.backends import SessionConfig @@ -395,6 +409,7 @@ def _upgrade_podman( ports=agent_instance.config.exposed_ports, otel_ports=otel_ports, otel_endpoint=otel_endpoint, + secret_env_names=secret_env_names, ) backend.create_session(session_config) @@ -568,6 +583,29 @@ def _upgrade_openshift( "--overwrite", ) + # Update secret-env annotation from project config (clear if empty) + if config is not None: + from paude.backends.shared import PAUDE_LABEL_SECRET_ENV + + secret_names = config.container_secret_env + invalid = [n for n in secret_names if "," in n] + if invalid: + typer.echo( + f"Error: secretEnv names cannot contain commas: {invalid}", + err=True, + ) + raise typer.Exit(1) + ann_value = ",".join(secret_names) if secret_names else "" + oc.run( + "annotate", + "statefulset", + sts_name, + "-n", + ns, + f"{PAUDE_LABEL_SECRET_ENV}={ann_value}", + "--overwrite", + ) + # Scale to 1 and wait typer.echo(f"Starting session '{name}'...", err=True) backend._lifecycle._scale_statefulset(name, 1) @@ -612,11 +650,14 @@ def _upgrade_openshift( typer.echo(f"Waiting for pod {pname} to be ready...", err=True) backend._pod_waiter.wait_for_ready(pname) - # Re-sync config + # Re-sync config (including custom secret env vars from project config) from paude.agents.base import build_secret_environment_from_config + from paude.backends.shared import build_custom_secret_env agent_instance = get_agent(agent_name, provider=provider_name) secret_env = build_secret_environment_from_config(agent_instance.config) + if config and config.container_secret_env: + secret_env.update(build_custom_secret_env(config.container_secret_env)) backend._syncer.sync_full_config( pname, agent_name=agent_name, provider=provider_name, secret_env=secret_env ) diff --git a/src/paude/config/models.py b/src/paude/config/models.py index 0793d85..a4e476b 100644 --- a/src/paude/config/models.py +++ b/src/paude/config/models.py @@ -44,6 +44,10 @@ class PaudeConfig: # Container environment variables container_env: dict[str, str] = field(default_factory=dict) + # Secret environment variable names (values read from host os.environ, + # injected securely via tmpfs/exec, never in container spec) + container_secret_env: list[str] = field(default_factory=list) + # Additional packages to install (paude.json format) packages: list[str] = field(default_factory=list) diff --git a/src/paude/config/parser.py b/src/paude/config/parser.py index a1514a2..3ee6b68 100644 --- a/src/paude/config/parser.py +++ b/src/paude/config/parser.py @@ -123,11 +123,18 @@ def _parse_devcontainer(config_file: Path, data: dict[str, Any]) -> PaudeConfig: # Parse containerEnv container_env = data.get("containerEnv", {}) + # Parse secretEnv from customizations.paude.secretEnv + paude_customizations = data.get("customizations", {}).get("paude", {}) + container_secret_env = paude_customizations.get("secretEnv", []) + if not isinstance(container_secret_env, list): + container_secret_env = [] + container_secret_env = [s for s in container_secret_env if isinstance(s, str)] + # Warn about unsupported properties _warn_unsupported_properties(data) # Parse create hints from customizations.paude.create - create_section = data.get("customizations", {}).get("paude", {}).get("create", {}) + create_section = paude_customizations.get("create", {}) create_allowed_domains, create_agent, create_provider, create_otel_endpoint = ( _parse_create_section(create_section) ) @@ -141,6 +148,7 @@ def _parse_devcontainer(config_file: Path, data: dict[str, Any]) -> PaudeConfig: features=features, post_create_command=post_create_command, container_env=container_env, + container_secret_env=container_secret_env, build_args=build_args, create_allowed_domains=create_allowed_domains, create_agent=create_agent, @@ -178,6 +186,12 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: file=sys.stderr, ) + # Parse secretEnv + container_secret_env = data.get("secretEnv", []) + if not isinstance(container_secret_env, list): + container_secret_env = [] + container_secret_env = [s for s in container_secret_env if isinstance(s, str)] + # Parse "create" section for create hints create_allowed_domains, create_agent, create_provider, create_otel_endpoint = ( _parse_create_section(data.get("create", {})) @@ -192,6 +206,7 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: build_args=build_args, packages=packages, post_create_command=setup_command, + container_secret_env=container_secret_env, create_allowed_domains=create_allowed_domains, create_agent=create_agent, create_provider=create_provider, diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 5d08df8..dbeefeb 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -173,7 +173,7 @@ def test_upgrade_podman_preserves_volume( mock_image_manager.ensure_default_image.return_value = "paude:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = (None, [], {}, False) + mock_prepare.return_value = (None, [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -236,6 +236,7 @@ def test_upgrade_podman_reads_labels( [], {}, False, + [], ) from paude.backends.podman.backend import PodmanBackend @@ -277,7 +278,7 @@ def test_upgrade_podman_rebuilds_image( mock_image_manager.ensure_default_image.return_value = "paude:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = (None, [], {}, False) + mock_prepare.return_value = (None, [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -318,7 +319,7 @@ def test_upgrade_podman_removes_proxy( mock_image_manager.ensure_proxy_image.return_value = "proxy:rebuilt" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -361,7 +362,7 @@ def test_upgrade_podman_no_proxy( mock_image_manager.ensure_default_image.return_value = "paude:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = (None, [], {}, False) + mock_prepare.return_value = (None, [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -1041,7 +1042,7 @@ def test_upgrade_adds_otel_endpoint( mock_image_manager.ensure_proxy_image.return_value = "proxy:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -1084,7 +1085,7 @@ def test_upgrade_clears_otel_endpoint( mock_image_manager.ensure_proxy_image.return_value = "proxy:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend @@ -1127,7 +1128,7 @@ def test_upgrade_preserves_existing_otel( mock_image_manager.ensure_proxy_image.return_value = "proxy:latest" mock_image_manager_class.return_value = mock_image_manager - mock_prepare.return_value = ([".googleapis.com"], [], {}, False) + mock_prepare.return_value = ([".googleapis.com"], [], {}, False, []) from paude.backends.podman.backend import PodmanBackend From 46940b3aeb3b625baa538a3d2b70e2459461f2c0 Mon Sep 17 00:00:00 2001 From: Michael Hess Date: Tue, 31 Mar 2026 06:39:26 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Extend=20secretEnv=20to=20support=20name=20?= =?UTF-8?q?mapping=20(container=20=E2=86=90=20host)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit secretEnv now accepts both formats: - list: ["FOO"] → same name on host and container - dict: {"CONTAINER_NAME": "HOST_NAME"} → rename on injection This enables injecting host env vars under different names inside the container, e.g. mapping a read-only token to the name an MCP server expects: "secretEnv": {"JIRA_API_TOKEN": "JIRA_TOKEN_READONLY"} Serialization in labels/annotations uses compact format: FOO,CONTAINER_NAME=HOST_NAME Also adds validation that names cannot contain commas or equals signs (used as serialization separators). Co-Authored-By: Claude Opus 4.6 --- src/paude/backends/base.py | 2 +- src/paude/backends/openshift/resources.py | 18 ++--- .../backends/openshift/session_connection.py | 6 +- .../backends/openshift/session_lifecycle.py | 10 +-- src/paude/backends/podman/backend.py | 38 ++++++----- src/paude/backends/shared.py | 67 ++++++++++++++----- src/paude/cli/create.py | 6 +- src/paude/cli/create_openshift.py | 4 +- src/paude/cli/create_podman.py | 4 +- src/paude/cli/helpers.py | 35 ++++++---- src/paude/cli/upgrade.py | 43 ++++++------ src/paude/config/models.py | 7 +- src/paude/config/parser.py | 28 +++++--- 13 files changed, 165 insertions(+), 103 deletions(-) diff --git a/src/paude/backends/base.py b/src/paude/backends/base.py index f50f2db..0b553d1 100644 --- a/src/paude/backends/base.py +++ b/src/paude/backends/base.py @@ -89,7 +89,7 @@ class SessionConfig: ports: list[tuple[int, int]] = field(default_factory=list) otel_ports: list[int] = field(default_factory=list) otel_endpoint: str | None = None - secret_env_names: list[str] = field(default_factory=list) + secret_env_mapping: dict[str, str] = field(default_factory=dict) class Backend(Protocol): diff --git a/src/paude/backends/openshift/resources.py b/src/paude/backends/openshift/resources.py index db5ac38..b0d4a66 100644 --- a/src/paude/backends/openshift/resources.py +++ b/src/paude/backends/openshift/resources.py @@ -83,7 +83,7 @@ def __init__( self._otel_endpoint: str | None = None self._env: dict[str, str] = {} self._workspace: Path | None = None - self._secret_env_names: list[str] = [] + self._secret_env_mapping: dict[str, str] = {} self._pvc_size = "10Gi" self._storage_class: str | None = None @@ -123,16 +123,16 @@ def with_workspace(self, workspace: Path) -> StatefulSetBuilder: self._workspace = workspace return self - def with_secret_env_names(self, names: list[str]) -> StatefulSetBuilder: - """Set custom secret env var names. + def with_secret_env_mapping(self, mapping: dict[str, str]) -> StatefulSetBuilder: + """Set custom secret env var mapping. Args: - names: List of env var names to inject securely. + mapping: Container name -> host name mapping. Returns: Self for method chaining. """ - self._secret_env_names = names + self._secret_env_mapping = mapping return self def with_pvc( @@ -184,9 +184,11 @@ def _build_metadata(self, created_at: str) -> dict[str, Any]: metadata["annotations"]["paude.io/workspace"] = encoded if self._otel_endpoint: metadata["annotations"]["paude.io/otel-endpoint"] = self._otel_endpoint - if self._secret_env_names: - metadata["annotations"][PAUDE_LABEL_SECRET_ENV] = ",".join( - self._secret_env_names + if self._secret_env_mapping: + from paude.backends.shared import serialize_secret_env_mapping + + metadata["annotations"][PAUDE_LABEL_SECRET_ENV] = ( + serialize_secret_env_mapping(self._secret_env_mapping) ) return metadata diff --git a/src/paude/backends/openshift/session_connection.py b/src/paude/backends/openshift/session_connection.py index 7d9b5ac..6ddc301 100644 --- a/src/paude/backends/openshift/session_connection.py +++ b/src/paude/backends/openshift/session_connection.py @@ -280,9 +280,9 @@ def _sync_for_connect( # Merge custom secret env vars from STS annotations annotations = (sts or {}).get("metadata", {}).get("annotations", {}) - custom_names = parse_secret_env_label(annotations.get(PAUDE_LABEL_SECRET_ENV)) - if custom_names: - secret_env.update(build_custom_secret_env(custom_names)) + custom_mapping = parse_secret_env_label(annotations.get(PAUDE_LABEL_SECRET_ENV)) + if custom_mapping: + secret_env.update(build_custom_secret_env(custom_mapping)) if self._syncer.is_config_synced(pname): self._syncer.sync_credentials( diff --git a/src/paude/backends/openshift/session_lifecycle.py b/src/paude/backends/openshift/session_lifecycle.py index a19e97e..4a058ef 100644 --- a/src/paude/backends/openshift/session_lifecycle.py +++ b/src/paude/backends/openshift/session_lifecycle.py @@ -135,8 +135,8 @@ def _build_session_env( agent = get_agent(config.agent, provider=config.provider) secret_env = build_secret_environment_from_config(agent.config) - if config.secret_env_names: - secret_env.update(build_custom_secret_env(config.secret_env_names)) + if config.secret_env_mapping: + secret_env.update(build_custom_secret_env(config.secret_env_mapping)) proxy_name = ( proxy_resource_name(session_name) if config.allowed_domains is not None @@ -174,7 +174,7 @@ def _apply_and_wait( gpu=config.gpu, yolo=config.yolo, otel_endpoint=config.otel_endpoint, - secret_env_names=config.secret_env_names, + secret_env_mapping=config.secret_env_mapping, ) print( @@ -400,7 +400,7 @@ def _generate_statefulset_spec( gpu: str | None = None, yolo: bool = False, otel_endpoint: str | None = None, - secret_env_names: list[str] | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> dict[str, Any]: """Generate a Kubernetes StatefulSet specification.""" return ( @@ -418,6 +418,6 @@ def _generate_statefulset_spec( .with_workspace(workspace) .with_pvc(size=pvc_size, storage_class=storage_class) .with_otel_endpoint(otel_endpoint) - .with_secret_env_names(secret_env_names or []) + .with_secret_env_mapping(secret_env_mapping or {}) .build() ) diff --git a/src/paude/backends/podman/backend.py b/src/paude/backends/podman/backend.py index 6a3f54b..dbce738 100644 --- a/src/paude/backends/podman/backend.py +++ b/src/paude/backends/podman/backend.py @@ -124,8 +124,8 @@ def _get_session_agent(self, session_name: str) -> Agent: provider = labels.get(PAUDE_LABEL_PROVIDER) or None return get_agent(agent_name, provider=provider) - def _get_secret_env_names(self, session_name: str) -> list[str]: - """Get custom secret env var names from session labels.""" + def _get_secret_env_mapping(self, session_name: str) -> dict[str, str]: + """Get custom secret env mapping from session labels.""" from paude.backends.shared import parse_secret_env_label labels = self._get_session_labels(session_name) @@ -173,7 +173,7 @@ def _build_attach_env( self, agent: Agent, github_token: str | None, - secret_env_names: list[str] | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> dict[str, str] | None: """Build extra environment for container attachment.""" from paude.agents.base import build_secret_environment_from_config @@ -186,8 +186,8 @@ def _build_attach_env( extra_env["GH_TOKEN"] = github_token extra_env.update(secret_env) - if secret_env_names: - extra_env.update(build_custom_secret_env(secret_env_names)) + if secret_env_mapping: + extra_env.update(build_custom_secret_env(secret_env_mapping)) port_urls = self._get_port_urls(agent) if port_urls: @@ -316,8 +316,12 @@ def create_session(self, config: SessionConfig) -> Session: ) if config.otel_endpoint: labels[PAUDE_LABEL_OTEL_ENDPOINT] = config.otel_endpoint - if config.secret_env_names: - labels[PAUDE_LABEL_SECRET_ENV] = ",".join(config.secret_env_names) + if config.secret_env_mapping: + from paude.backends.shared import serialize_secret_env_mapping + + labels[PAUDE_LABEL_SECRET_ENV] = serialize_secret_env_mapping( + config.secret_env_mapping + ) print(f"Creating session '{session_name}'...", file=sys.stderr) @@ -450,18 +454,18 @@ def start_session_no_attach( agent = self._get_session_agent(name) self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) - secret_names = self._get_secret_env_names(name) + secret_mapping = self._get_secret_env_mapping(name) self._start_agent_headless_in_container( - cname, agent, github_token, secret_env_names=secret_names + cname, agent, github_token, secret_env_mapping=secret_mapping ) def start_agent_headless(self, name: str, github_token: str | None = None) -> None: """Start the agent in headless mode inside the container.""" cname = self._require_running_session(name) agent = self._get_session_agent(name) - secret_names = self._get_secret_env_names(name) + secret_mapping = self._get_secret_env_mapping(name) self._start_agent_headless_in_container( - cname, agent, github_token, secret_env_names=secret_names + cname, agent, github_token, secret_env_mapping=secret_mapping ) def _start_agent_headless_in_container( @@ -469,11 +473,11 @@ def _start_agent_headless_in_container( cname: str, agent: Agent, github_token: str | None = None, - secret_env_names: list[str] | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> None: """Start the agent in headless mode (internal, skips session lookup).""" env_vars = self._build_attach_env( - agent, github_token=github_token, secret_env_names=secret_env_names + agent, github_token=github_token, secret_env_mapping=secret_env_mapping ) cmd: list[str] = ["env", "PAUDE_HEADLESS=1"] if env_vars: @@ -557,14 +561,14 @@ def start_session(self, name: str, github_token: str | None = None) -> int: self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) - secret_names = self._get_secret_env_names(name) + secret_mapping = self._get_secret_env_mapping(name) self._start_port_forward(name, agent) self._print_port_urls(name, agent) exit_code = self._runner.attach_container( cname, entrypoint=CONTAINER_ENTRYPOINT, extra_env=self._build_attach_env( - agent, github_token, secret_env_names=secret_names + agent, github_token, secret_env_mapping=secret_mapping ), ) self._print_port_urls(name, agent) @@ -627,7 +631,7 @@ def connect_session(self, name: str, github_token: str | None = None) -> int: self._sync_host_config(cname, agent.config.name) self._sync_sandbox_config(cname, name) - secret_names = self._get_secret_env_names(name) + secret_mapping = self._get_secret_env_mapping(name) self._start_port_forward(name, agent) print(f"Connecting to session '{name}'...", file=sys.stderr) self._print_port_urls(name, agent) @@ -636,7 +640,7 @@ def connect_session(self, name: str, github_token: str | None = None) -> int: cname, entrypoint=CONTAINER_ENTRYPOINT, extra_env=self._build_attach_env( - agent, github_token, secret_env_names=secret_names + agent, github_token, secret_env_mapping=secret_mapping ), ) finally: diff --git a/src/paude/backends/shared.py b/src/paude/backends/shared.py index 5ead878..62298c6 100644 --- a/src/paude/backends/shared.py +++ b/src/paude/backends/shared.py @@ -224,41 +224,78 @@ def generate_sandbox_config_script( return agent.apply_sandbox_config(CONTAINER_HOME, workspace, args) -def build_custom_secret_env(secret_env_names: list[str]) -> dict[str, str]: - """Build secret env dict from a list of var names, reading values from os.environ. +def build_custom_secret_env( + secret_env_mapping: dict[str, str], +) -> dict[str, str]: + """Build secret env dict from a mapping, reading values from os.environ. Args: - secret_env_names: List of environment variable names to look up. + secret_env_mapping: Mapping of container_name -> host_name. Returns: - Dictionary of var_name -> value for vars present in os.environ. + Dictionary of container_name -> value for vars present in os.environ. """ env: dict[str, str] = {} - for var_name in secret_env_names: - value = os.environ.get(var_name) + for container_name, host_name in secret_env_mapping.items(): + value = os.environ.get(host_name) if value is not None: - env[var_name] = value + env[container_name] = value else: + if container_name == host_name: + msg = f"Warning: secretEnv variable '{host_name}'" + else: + msg = ( + f"Warning: secretEnv variable '{host_name}'" + f" (mapped to '{container_name}')" + ) print( - f"Warning: secretEnv variable '{var_name}' " - "not found in host environment", + f"{msg} not found in host environment", file=sys.stderr, ) return env -def parse_secret_env_label(label_value: str | None) -> list[str]: - """Parse the secret-env label value into a list of env var names. +def serialize_secret_env_mapping(mapping: dict[str, str]) -> str: + """Serialize a secret env mapping for storage in labels/annotations. + + Same-name mappings are stored as just the name. + Renamed mappings are stored as container_name=host_name. + Entries are comma-separated. + """ + parts: list[str] = [] + for container_name, host_name in mapping.items(): + if container_name == host_name: + parts.append(container_name) + else: + parts.append(f"{container_name}={host_name}") + return ",".join(parts) + + +def parse_secret_env_label(label_value: str | None) -> dict[str, str]: + """Parse a secret-env label/annotation into a mapping. Args: - label_value: Comma-separated env var names, or None. + label_value: Serialized mapping string, or None. Returns: - List of env var names. + Mapping of container_name -> host_name. """ if not label_value: - return [] - return [n.strip() for n in label_value.split(",") if n.strip()] + return {} + mapping: dict[str, str] = {} + for entry in label_value.split(","): + entry = entry.strip() + if not entry: + continue + if "=" in entry: + container_name, host_name = entry.split("=", 1) + container_name = container_name.strip() + host_name = host_name.strip() + if container_name and host_name: + mapping[container_name] = host_name + else: + mapping[entry] = entry + return mapping def build_ssh_backend( diff --git a/src/paude/cli/create.py b/src/paude/cli/create.py index ac8867a..9c15858 100644 --- a/src/paude/cli/create.py +++ b/src/paude/cli/create.py @@ -322,7 +322,7 @@ def session_create( raise typer.Exit(1) from None # Shared pre-create: parse args, build env, expand domains, show warnings - expanded_domains, parsed_args, env, unrestricted, secret_env_names = ( + expanded_domains, parsed_args, env, unrestricted, secret_env_mapping = ( _prepare_session_create( allowed_domains=r_allowed_domains, yolo=r_yolo, @@ -366,7 +366,7 @@ def session_create( gpu=r_gpu, otel_ports=otel_ports, otel_endpoint=r_otel_endpoint, - secret_env_names=secret_env_names, + secret_env_mapping=secret_env_mapping, ) else: from paude.cli.create_openshift import create_openshift_session @@ -395,5 +395,5 @@ def session_create( build_resources=r_openshift_build_resources, otel_ports=otel_ports, otel_endpoint=r_otel_endpoint, - secret_env_names=secret_env_names, + secret_env_mapping=secret_env_mapping, ) diff --git a/src/paude/cli/create_openshift.py b/src/paude/cli/create_openshift.py index 3c99dc6..53ebac3 100644 --- a/src/paude/cli/create_openshift.py +++ b/src/paude/cli/create_openshift.py @@ -50,7 +50,7 @@ def create_openshift_session( build_resources: dict[str, dict[str, str]] | None = None, otel_ports: list[int] | None = None, otel_endpoint: str | None = None, - secret_env_names: list[str] | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> None: """OpenShift-specific session creation logic.""" os_script_dir = _detect_dev_script_dir() @@ -124,7 +124,7 @@ def create_openshift_session( ports=agent.config.exposed_ports, otel_ports=otel_ports or [], otel_endpoint=otel_endpoint, - secret_env_names=secret_env_names or [], + secret_env_mapping=secret_env_mapping or {}, ) session = os_backend.create_session(session_config) diff --git a/src/paude/cli/create_podman.py b/src/paude/cli/create_podman.py index c2feb87..679ec94 100644 --- a/src/paude/cli/create_podman.py +++ b/src/paude/cli/create_podman.py @@ -45,7 +45,7 @@ def create_podman_session( gpu: str | None = None, otel_ports: list[int] | None = None, otel_endpoint: str | None = None, - secret_env_names: list[str] | None = None, + secret_env_mapping: dict[str, str] | None = None, ) -> None: """Local container session creation logic (Podman or Docker).""" from paude.container import ImageManager @@ -116,7 +116,7 @@ def create_podman_session( ports=agent_instance.config.exposed_ports, otel_ports=otel_ports or [], otel_endpoint=otel_endpoint, - secret_env_names=secret_env_names or [], + secret_env_mapping=secret_env_mapping or {}, ) try: diff --git a/src/paude/cli/helpers.py b/src/paude/cli/helpers.py index 8f38721..2742615 100644 --- a/src/paude/cli/helpers.py +++ b/src/paude/cli/helpers.py @@ -257,6 +257,20 @@ def _expand_allowed_domains( return expand_domains(domains_input, extra_aliases=extra_aliases) +def _validate_secret_env_names(mapping: dict[str, str]) -> None: + """Validate that secret env names don't contain commas or equals signs. + + These characters are used as separators in label/annotation storage. + """ + bad = [n for n in (*mapping.keys(), *mapping.values()) if "," in n or "=" in n] + if bad: + typer.echo( + f"Error: secretEnv names cannot contain commas or equals signs: {bad}", + err=True, + ) + raise typer.Exit(1) + + def _prepare_session_create( allowed_domains: list[str] | None, yolo: bool, @@ -265,11 +279,12 @@ def _prepare_session_create( agent_name: str = "claude", provider_name: str | None = None, otel_endpoint: str | None = None, -) -> tuple[list[str] | None, list[str], dict[str, str], bool, list[str]]: +) -> tuple[list[str] | None, list[str], dict[str, str], bool, dict[str, str]]: """Shared pre-create logic for both backends. Returns: - Tuple of (expanded_domains, parsed_args, env, unrestricted, secret_env_names). + Tuple of (expanded_domains, parsed_args, env, unrestricted, + secret_env_mapping). """ from paude.agents import get_agent from paude.domains import is_unrestricted @@ -298,17 +313,11 @@ def _prepare_session_create( unrestricted = is_unrestricted(expanded_domains) - # Extract secret env var names from config - secret_env_names: list[str] = [] + # Extract secret env mapping from config + secret_env_mapping: dict[str, str] = {} if config_obj and config_obj.container_secret_env: - invalid = [n for n in config_obj.container_secret_env if "," in n] - if invalid: - typer.echo( - f"Error: secretEnv names cannot contain commas: {invalid}", - err=True, - ) - raise typer.Exit(1) - secret_env_names = list(config_obj.container_secret_env) + _validate_secret_env_names(config_obj.container_secret_env) + secret_env_mapping = dict(config_obj.container_secret_env) # Show warnings for dangerous configurations if yolo and unrestricted: @@ -322,7 +331,7 @@ def _prepare_session_create( ) typer.echo("", err=True) - return expanded_domains, parsed_args, env, unrestricted, secret_env_names + return expanded_domains, parsed_args, env, unrestricted, secret_env_mapping def _finalize_session_create( diff --git a/src/paude/cli/upgrade.py b/src/paude/cli/upgrade.py index 9118600..8d62719 100644 --- a/src/paude/cli/upgrade.py +++ b/src/paude/cli/upgrade.py @@ -352,7 +352,7 @@ def _upgrade_podman( # allowed_domains=None means no proxy (unrestricted); # passing None to _prepare_session_create would incorrectly add defaults. if allowed_domains is not None: - expanded_domains, parsed_args, _env, unrestricted, _secret_names = ( + expanded_domains, parsed_args, _env, unrestricted, _secret_mapping = ( _prepare_session_create( allowed_domains, yolo, @@ -378,17 +378,13 @@ def _upgrade_podman( otel_ports = otel_proxy_ports(otel_endpoint) - # Extract secret env names from config - secret_env_names: list[str] = [] + # Extract secret env mapping from config + secret_env_mapping: dict[str, str] = {} if config and config.container_secret_env: - invalid = [n for n in config.container_secret_env if "," in n] - if invalid: - typer.echo( - f"Error: secretEnv names cannot contain commas: {invalid}", - err=True, - ) - raise typer.Exit(1) - secret_env_names = list(config.container_secret_env) + from paude.cli.helpers import _validate_secret_env_names + + _validate_secret_env_names(config.container_secret_env) + secret_env_mapping = dict(config.container_secret_env) # Create new session config with reuse_volume=True from paude.backends import SessionConfig @@ -409,7 +405,7 @@ def _upgrade_podman( ports=agent_instance.config.exposed_ports, otel_ports=otel_ports, otel_endpoint=otel_endpoint, - secret_env_names=secret_env_names, + secret_env_mapping=secret_env_mapping, ) backend.create_session(session_config) @@ -585,17 +581,18 @@ def _upgrade_openshift( # Update secret-env annotation from project config (clear if empty) if config is not None: - from paude.backends.shared import PAUDE_LABEL_SECRET_ENV - - secret_names = config.container_secret_env - invalid = [n for n in secret_names if "," in n] - if invalid: - typer.echo( - f"Error: secretEnv names cannot contain commas: {invalid}", - err=True, - ) - raise typer.Exit(1) - ann_value = ",".join(secret_names) if secret_names else "" + from paude.backends.shared import ( + PAUDE_LABEL_SECRET_ENV, + serialize_secret_env_mapping, + ) + from paude.cli.helpers import _validate_secret_env_names + + secret_mapping = config.container_secret_env + if secret_mapping: + _validate_secret_env_names(secret_mapping) + ann_value = ( + serialize_secret_env_mapping(secret_mapping) if secret_mapping else "" + ) oc.run( "annotate", "statefulset", diff --git a/src/paude/config/models.py b/src/paude/config/models.py index a4e476b..f97cc18 100644 --- a/src/paude/config/models.py +++ b/src/paude/config/models.py @@ -44,9 +44,10 @@ class PaudeConfig: # Container environment variables container_env: dict[str, str] = field(default_factory=dict) - # Secret environment variable names (values read from host os.environ, - # injected securely via tmpfs/exec, never in container spec) - container_secret_env: list[str] = field(default_factory=list) + # Secret environment variable mapping (container_name -> host_name). + # Values read from host os.environ, injected securely via tmpfs/exec, + # never in container spec. Supports both list (same name) and dict (rename). + container_secret_env: dict[str, str] = field(default_factory=dict) # Additional packages to install (paude.json format) packages: list[str] = field(default_factory=list) diff --git a/src/paude/config/parser.py b/src/paude/config/parser.py index 3ee6b68..b37fa2c 100644 --- a/src/paude/config/parser.py +++ b/src/paude/config/parser.py @@ -125,10 +125,7 @@ def _parse_devcontainer(config_file: Path, data: dict[str, Any]) -> PaudeConfig: # Parse secretEnv from customizations.paude.secretEnv paude_customizations = data.get("customizations", {}).get("paude", {}) - container_secret_env = paude_customizations.get("secretEnv", []) - if not isinstance(container_secret_env, list): - container_secret_env = [] - container_secret_env = [s for s in container_secret_env if isinstance(s, str)] + container_secret_env = _parse_secret_env(paude_customizations.get("secretEnv")) # Warn about unsupported properties _warn_unsupported_properties(data) @@ -187,10 +184,7 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: ) # Parse secretEnv - container_secret_env = data.get("secretEnv", []) - if not isinstance(container_secret_env, list): - container_secret_env = [] - container_secret_env = [s for s in container_secret_env if isinstance(s, str)] + container_secret_env = _parse_secret_env(data.get("secretEnv")) # Parse "create" section for create hints create_allowed_domains, create_agent, create_provider, create_otel_endpoint = ( @@ -214,6 +208,24 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: ) +def _parse_secret_env(raw: object) -> dict[str, str]: + """Parse secretEnv config into a container_name -> host_name mapping. + + Accepts either: + - list of strings: ["FOO", "BAR"] -> {"FOO": "FOO", "BAR": "BAR"} + - dict: {"CONTAINER_NAME": "HOST_NAME"} -> passed through + """ + if raw is None: + return {} + if isinstance(raw, dict): + return { + k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str) + } + if isinstance(raw, list): + return {s: s for s in raw if isinstance(s, str)} + return {} + + _KNOWN_CREATE_KEYS = {"allowed-domains", "agent", "provider", "otel-endpoint"} From 2a09d2094fa1705f0528c26992faf9bbbadf3669 Mon Sep 17 00:00:00 2001 From: Michael Hess Date: Wed, 1 Apr 2026 10:44:45 +0000 Subject: [PATCH 3/3] Document secretEnv feature and simplify to dict-only format Remove list format support (never published) to align with devcontainer conventions. Add documentation to CONFIGURATION.md, SECURITY.md, and OPENSHIFT.md. Co-Authored-By: Claude Opus 4.6 --- docs/CONFIGURATION.md | 41 ++++++++++++++++++++++++++++++++++++++ docs/OPENSHIFT.md | 11 ++++++++-- docs/SECURITY.md | 1 + src/paude/config/models.py | 2 +- src/paude/config/parser.py | 7 ++----- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7f40901..f066b8f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -328,6 +328,47 @@ See [`examples/README.md`](../examples/README.md) for more configurations (Pytho | `features` | Dev container features (ghcr.io OCI artifacts) | | `postCreateCommand` | Run after first start | | `containerEnv` | Environment variables | +| `customizations.paude.secretEnv` | Secret env vars (see below) | + +## Secret Environment Variables + +Inject security-sensitive environment variables (API tokens, credentials) into sessions without exposing them in container specs or process listings. Values are read from your host environment at connect/start time and injected securely. + +The format is `"CONTAINER_NAME": "HOST_NAME"` — the key is the variable name inside the container, the value is the variable name on your host. Use the same name for both when no remapping is needed. + +**paude.json** — use a top-level `secretEnv`: + +```json +{ + "secretEnv": { + "JIRA_API_TOKEN": "JIRA_TOKEN_READONLY", + "SLACK_BOT_TOKEN": "SLACK_BOT_TOKEN" + } +} +``` + +**devcontainer.json** — use `customizations.paude.secretEnv`: + +```json +{ + "customizations": { + "paude": { + "secretEnv": { + "JIRA_API_TOKEN": "JIRA_TOKEN_READONLY" + } + } + } +} +``` + +In the examples above, the host's `JIRA_TOKEN_READONLY` is read and exposed as `JIRA_API_TOKEN` inside the container. `SLACK_BOT_TOKEN` uses the same name on both sides. + +### How It Works + +- **Podman/Docker**: Injected via `podman exec -e` on connect — never stored in the container spec. +- **OpenShift**: Written to tmpfs at `/credentials/env/` on connect — RAM-only, never persisted to disk. +- Fresh values are read on every `paude connect` or `paude start`, so rotated tokens propagate without restarting the container. +- If a declared variable is missing from your host environment, a warning is printed and that variable is skipped (not set in the container). The session still starts normally. ## GPU Passthrough diff --git a/docs/OPENSHIFT.md b/docs/OPENSHIFT.md index 68a2f60..128e15b 100644 --- a/docs/OPENSHIFT.md +++ b/docs/OPENSHIFT.md @@ -138,9 +138,16 @@ Configuration is synced via `oc cp` to tmpfs on session start and reconnect: Plugin paths are automatically rewritten from host paths to container paths. +**Custom Secret Env Vars (`secretEnv`):** +- Declared in `paude.json` or `devcontainer.json` (see [CONFIGURATION.md](CONFIGURATION.md#secret-environment-variables)) +- Written to tmpfs at `/credentials/env/` alongside built-in credentials +- Supports name mapping: host env var names can differ from container names +- Mapping is stored in StatefulSet annotations so it survives across connect/start/upgrade +- Values are read fresh from the host on every connect + **Credential Refresh:** -- **First connect** (after pod start): Full sync of gcloud, claude config, and gitconfig -- **Reconnect** (subsequent connects): Only gcloud credentials refreshed (fast) +- **First connect** (after pod start): Full sync of gcloud, claude config, gitconfig, and secretEnv +- **Reconnect** (subsequent connects): Only gcloud credentials and secretEnv refreshed (fast) - This ensures fresh OAuth tokens propagate if you re-authenticate locally - Long-running pods stay current with local credential changes diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e9b007d..ee09b10 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -12,6 +12,7 @@ The container intentionally restricts certain operations: | SSH keys | not mounted | Prevents git push via SSH | | GitHub CLI config | not mounted | Prevents cached host credentials | | `GH_TOKEN` (host) | never propagated | Use `PAUDE_GITHUB_TOKEN` or `--github-token` on start/connect | +| Custom secrets (`secretEnv`) | injected (exec -e / tmpfs) | User-defined secret env vars; never in container spec | | Git credentials | not mounted | Prevents HTTPS git push | ## Verified Attack Vectors diff --git a/src/paude/config/models.py b/src/paude/config/models.py index f97cc18..699bc71 100644 --- a/src/paude/config/models.py +++ b/src/paude/config/models.py @@ -46,7 +46,7 @@ class PaudeConfig: # Secret environment variable mapping (container_name -> host_name). # Values read from host os.environ, injected securely via tmpfs/exec, - # never in container spec. Supports both list (same name) and dict (rename). + # never in container spec. container_secret_env: dict[str, str] = field(default_factory=dict) # Additional packages to install (paude.json format) diff --git a/src/paude/config/parser.py b/src/paude/config/parser.py index b37fa2c..0299fb2 100644 --- a/src/paude/config/parser.py +++ b/src/paude/config/parser.py @@ -211,9 +211,8 @@ def _parse_paude_json(config_file: Path, data: dict[str, Any]) -> PaudeConfig: def _parse_secret_env(raw: object) -> dict[str, str]: """Parse secretEnv config into a container_name -> host_name mapping. - Accepts either: - - list of strings: ["FOO", "BAR"] -> {"FOO": "FOO", "BAR": "BAR"} - - dict: {"CONTAINER_NAME": "HOST_NAME"} -> passed through + Format: {"CONTAINER_NAME": "HOST_NAME"} + Use the same name for both when no remapping is needed. """ if raw is None: return {} @@ -221,8 +220,6 @@ def _parse_secret_env(raw: object) -> dict[str, str]: return { k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str) } - if isinstance(raw, list): - return {s: s for s in raw if isinstance(s, str)} return {}