From 2614389fbcd4a23cc9c3bfb816b3da6262e11981 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 16 Jul 2026 18:19:35 -0400 Subject: [PATCH] feat(centaur): a values knob for uncredentialed egress; allow PyPI so tools can run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No Centaur tool can execute in an Atrium sandbox. `centaur-tools run` invokes `uvx --from`, which resolves a tool's deps — and the hatchling build backend — from a package index at run time. Under iron-control, sandbox egress is deny-by-default, and PyPI has no credential, so the fetch is refused: Failed to fetch: `https://pypi.org/simple/hatchling/` tunnel error: unsuccessful This is not per-tool: a zero-dependency probe tool fails the same way, in a live prod sandbox. It is why values.local.yaml carries TOOL_ALLOWLIST: "none" — the 78 upstream tools are hidden because they are dead, not unwanted. Two correct designs disagree. Upstream's iron-proxy is a credential injector whose default is domains:["*"], and PyPI-at-run-time IS its intended design. Atrium sets console.enabled for per-user BYO credentials, which flips the proxy to managed mode and silently converts egress to deny-by-default. Nothing broke; we opted into a posture the tool mechanism never assumed. The seam already exists. centaur-console computes a sandbox's allowlist as union(baseline domains, hosts from granted credentials' rules) — and domains_from_rules only reads hosts off granted secrets, so an uncredentialed host can ONLY arrive via the baseline. api-rs builds that baseline from the infra fragment's non-secret transforms, and `allowlist` is not secret-bearing, so an allowlist transform flows straight through. CODEX_ACCESS_TOKEN_PER_USER_FRAGMENT is the precedent: allowlist chatgpt.com, inject nothing. So: ironProxy.extraAllowlistDomains -> KUBERNETES_IRON_PROXY_EXTRA_ALLOWLIST_DOMAINS -> an allowlist-only fragment merged into the infra fragment. Empty by default, so every other deployment's allowlist is byte-identical to today. Atrium turns on pypi.org + files.pythonhosted.org. Safety rests on the merge being a UNION, not a replace — a replace would strip chatgpt.com and cut model egress on prod. Verified three ways: the console's `allowlist_domains += domains_from_rules(config)`; merge_fragment being a plain extend; and a test asserting both allowlists survive so a future refactor to replace-semantics fails loudly rather than in production. Verified: cargo test (11 pass), clippy, fmt; helm renders "pypi.org,files.pythonhosted.org" under Atrium's values and "" under chart defaults. Not yet proven end-to-end in a sandbox — that needs a deploy. Background: docs/archive/notes/2026-07-16-centaur-tool-egress-and-deps.md (#557) --- centaur/contrib/chart/templates/apirs.yaml | 2 + centaur/contrib/chart/values.yaml | 15 +++++ .../crates/centaur-api-server/src/args.rs | 23 ++++++- .../crates/centaur-iron-proxy/src/fragment.rs | 46 ++++++++++++- .../crates/centaur-iron-proxy/src/lib.rs | 5 +- .../crates/centaur-iron-proxy/src/tests.rs | 64 +++++++++++++++++++ infra/values.local.yaml | 10 +++ 7 files changed, 160 insertions(+), 5 deletions(-) diff --git a/centaur/contrib/chart/templates/apirs.yaml b/centaur/contrib/chart/templates/apirs.yaml index ac349d522..3f0c42419 100644 --- a/centaur/contrib/chart/templates/apirs.yaml +++ b/centaur/contrib/chart/templates/apirs.yaml @@ -353,6 +353,8 @@ spec: value: {{ .Values.ironProxy.image.pullPolicy | quote }} - name: KUBERNETES_IRON_PROXY_UPSTREAM_DENY_CIDRS value: {{ join "," .Values.ironProxy.upstreamDenyCidrs | quote }} + - name: KUBERNETES_IRON_PROXY_EXTRA_ALLOWLIST_DOMAINS + value: {{ join "," .Values.ironProxy.extraAllowlistDomains | quote }} - name: KUBERNETES_FIREWALL_CA_SECRET_NAME value: {{ include "centaur.trustedCaSecretName" . | quote }} - name: KUBERNETES_FIREWALL_CA_KEY_SECRET_NAME diff --git a/centaur/contrib/chart/values.yaml b/centaur/contrib/chart/values.yaml index fbec633e6..beca6ca74 100644 --- a/centaur/contrib/chart/values.yaml +++ b/centaur/contrib/chart/values.yaml @@ -32,6 +32,21 @@ ironProxy: # matches. Keep the local/IMDS defaults and include the default k3s pod/service # CIDRs so sandboxes cannot proxy back into in-cluster services like # onepassword-connect. + # Hosts a sandbox may reach that no credential vouches for. + # + # Under iron-control a sandbox's egress allowlist is + # union(baseline domains, hosts from granted credentials' rules) — see + # centaur-console's Proxy.merge_proxy_policy. Only credentialed hosts can arrive + # via the second half, so an uncredentialed host (a package index, say) is only + # reachable if a deployment names it here. Domains listed here are UNIONED in: + # they widen the allowlist and cannot displace a harness's own entry. + # + # Empty by default — the allowlist is then exactly what the credentials imply. + # Note that `centaur-tools run` executes tools via `uvx --from`, which resolves + # each tool's deps (and the hatchling build backend) from a package index at + # run time, so a deployment running tools under iron-control needs + # ["pypi.org", "files.pythonhosted.org"] here or no tool can start. + extraAllowlistDomains: [] upstreamDenyCidrs: - "169.254.169.254/32" - "fd00:ec2::254/128" diff --git a/centaur/services/api-rs/crates/centaur-api-server/src/args.rs b/centaur/services/api-rs/crates/centaur-api-server/src/args.rs index fb287e5bb..d7f39b0de 100644 --- a/centaur/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/centaur/services/api-rs/crates/centaur-api-server/src/args.rs @@ -20,8 +20,8 @@ use centaur_iron_control::{ SessionRegistrar, register_proxy_baseline, register_role, }; use centaur_iron_proxy::{ - ProxyFragment, SourceKind, SourcePolicy, bedrock_enabled, harness_auth_fragment, - infra_fragment, per_user_harness_auth_fragment, + ProxyFragment, SourceKind, SourcePolicy, bedrock_enabled, extra_allowlist_fragment, + harness_auth_fragment, infra_fragment, per_user_harness_auth_fragment, }; use centaur_sandbox_agent_k8s::{ AgentSandboxBackend, AgentSandboxConfig, GitHubTokenRef, IronControlSettings, IronProxyConfig, @@ -1768,6 +1768,19 @@ struct IronProxyArgs { value_delimiter = ',' )] upstream_deny_cidrs: Vec, + /// Hosts a sandbox may reach that no credential vouches for. + /// + /// Under iron-control, egress is `union(baseline domains, hosts from granted + /// credentials)`, and only credentialed hosts can arrive via the latter. A + /// deployment that needs an uncredentialed host reachable — a package index, + /// say, which `uvx` hits at tool-run time — declares it here. Empty by + /// default: the allowlist is then exactly what the credentials imply. + #[arg( + long = "kubernetes-iron-proxy-extra-allowlist-domains", + env = "KUBERNETES_IRON_PROXY_EXTRA_ALLOWLIST_DOMAINS", + value_delimiter = ',' + )] + extra_allowlist_domains: Vec, #[command(flatten)] ca: IronProxyCaArgs, #[command(flatten)] @@ -1842,6 +1855,12 @@ impl IronProxyArgs { for fragment in self.harness.fragments()? { merge_fragment(&mut infra, fragment); } + // Appended last, and only when configured. The console unions the domains + // of every `allowlist` transform, so this widens the baseline without + // displacing the harness's own entry (losing that would cut model egress). + if let Some(fragment) = extra_allowlist_fragment(&self.extra_allowlist_domains) { + merge_fragment(&mut infra, fragment); + } Ok(infra) } diff --git a/centaur/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs b/centaur/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs index 5e63bd5ee..2df56d1e1 100644 --- a/centaur/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs +++ b/centaur/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs @@ -1,6 +1,8 @@ use std::{collections::BTreeMap, path::PathBuf}; -use crate::{IronProxyConfigError, ProxyFragment, Result}; +use serde_yaml::Value; + +use crate::{IronProxyConfigError, ProxyFragment, Result, Transform, TransformConfig}; /// The shared infra secrets, embedded at compile time so the binary carries no /// runtime config-file dependency. The source lives in this crate so it's @@ -14,6 +16,48 @@ pub fn load_fragment_str(contents: &str) -> Result { }) } +/// A deployment-configured `allowlist` transform: hosts a sandbox may reach that +/// no credential vouches for. +/// +/// Under iron-control (managed mode) a sandbox's egress allowlist is +/// `union(baseline domains, hosts from granted credentials' rules)` — see +/// centaur-console's `Proxy.merge_proxy_policy`. `domains_from_rules` only reads +/// hosts off granted secrets, so a host with no credential (a package index, say) +/// can only ever be reached by way of the baseline. This fragment is how a +/// deployment puts one there. +/// +/// Carries no secret deliberately, exactly like +/// `CODEX_ACCESS_TOKEN_PER_USER_FRAGMENT`: allow the host, inject nothing. +/// Because the console unions the domains of *every* transform named `allowlist`, +/// appending this can only widen the allowlist — it cannot displace the harness's +/// own entry. +/// +/// Returns `None` when no domains are configured, so the default deployment adds +/// no transform at all and its allowlist is unchanged. +pub fn extra_allowlist_fragment(domains: &[String]) -> Option { + let domains: Vec = domains + .iter() + .map(|domain| domain.trim()) + .filter(|domain| !domain.is_empty()) + .map(|domain| Value::String(domain.to_owned())) + .collect(); + if domains.is_empty() { + return None; + } + Some(ProxyFragment { + transforms: vec![Transform { + name: "allowlist".to_owned(), + config: TransformConfig { + secrets: Vec::new(), + extra: BTreeMap::from([("domains".to_owned(), Value::Sequence(domains))]), + }, + extra: BTreeMap::new(), + }], + postgres: Vec::new(), + top_level: BTreeMap::new(), + }) +} + /// The harness auth fragment for ``engine`` and ``auth_mode``. These are infra /// — known in advance — so they are baked in rather than discovered from disk. /// Returns ``None`` for an unknown engine/mode pair. diff --git a/centaur/services/api-rs/crates/centaur-iron-proxy/src/lib.rs b/centaur/services/api-rs/crates/centaur-iron-proxy/src/lib.rs index 9495fdd4e..8b2a10d59 100644 --- a/centaur/services/api-rs/crates/centaur-iron-proxy/src/lib.rs +++ b/centaur/services/api-rs/crates/centaur-iron-proxy/src/lib.rs @@ -5,8 +5,9 @@ mod source; pub use error::{IronProxyConfigError, Result}; pub use fragment::{ - bedrock_enabled, bedrock_region, bedrock_sandbox_env, harness_auth_fragment, infra_fragment, - load_fragment_str, per_user_harness_auth_fragment, pg_sandbox_dsns, placeholder_env, + bedrock_enabled, bedrock_region, bedrock_sandbox_env, extra_allowlist_fragment, + harness_auth_fragment, infra_fragment, load_fragment_str, per_user_harness_auth_fragment, + pg_sandbox_dsns, placeholder_env, }; pub use model::{ PgDsnSetting, PgDsnSettingValueFrom, PostgresClient, PostgresListener, PostgresUpstream, diff --git a/centaur/services/api-rs/crates/centaur-iron-proxy/src/tests.rs b/centaur/services/api-rs/crates/centaur-iron-proxy/src/tests.rs index aa64bc5f0..1ab49143e 100644 --- a/centaur/services/api-rs/crates/centaur-iron-proxy/src/tests.rs +++ b/centaur/services/api-rs/crates/centaur-iron-proxy/src/tests.rs @@ -119,3 +119,67 @@ fn shipped_proxy_allowlist_preserves_railway_project_tokens() { .any(|header| header.as_str() == Some("project-access-token")) ); } + +#[test] +fn extra_allowlist_fragment_is_empty_by_default() { + // The default deployment must add no transform at all, so its allowlist stays + // exactly what the granted credentials imply. + assert!(extra_allowlist_fragment(&[]).is_none()); + assert!(extra_allowlist_fragment(&["".to_owned(), " ".to_owned()]).is_none()); +} + +#[test] +fn extra_allowlist_fragment_declares_domains_and_no_secret() { + let fragment = + extra_allowlist_fragment(&["pypi.org".to_owned(), " files.pythonhosted.org ".to_owned()]) + .expect("domains configured"); + let transform = match fragment.transforms.as_slice() { + [transform] => transform, + other => panic!("expected exactly one transform, got {}", other.len()), + }; + assert_eq!(transform.name, "allowlist"); + // Allow the host, inject nothing — the same shape as the per-user codex + // fragment. A secret here would make iron-control treat it as a credential. + assert!(transform.config.secrets.is_empty()); + assert_eq!( + transform.config.extra.get("domains"), + Some(&serde_yaml::Value::Sequence(vec![ + serde_yaml::Value::String("pypi.org".to_owned()), + serde_yaml::Value::String("files.pythonhosted.org".to_owned()), + ])) + ); +} + +#[test] +fn extra_allowlist_fragment_does_not_displace_the_harness_allowlist() { + // The property that makes this safe to deploy: centaur-console unions the + // domains of EVERY transform named `allowlist` (Proxy.split_allowlist_transforms), + // so appending ours widens egress rather than replacing codex's chatgpt.com. + // If this ever became a replace, prod would lose model egress. + let mut merged = per_user_harness_auth_fragment("codex", "access_token") + .unwrap() + .expect("codex per-user fragment"); + let extra = extra_allowlist_fragment(&["pypi.org".to_owned()]).expect("domains configured"); + merged.transforms.extend(extra.transforms); + + let allowlists: Vec<&Transform> = merged + .transforms + .iter() + .filter(|transform| transform.name == "allowlist") + .collect(); + assert_eq!( + allowlists.len(), + 2, + "both allowlists must survive the merge" + ); + + let domains: Vec = allowlists + .iter() + .filter_map(|transform| transform.config.extra.get("domains")) + .filter_map(|value| value.as_sequence()) + .flatten() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect(); + assert!(domains.contains(&"chatgpt.com".to_owned())); + assert!(domains.contains(&"pypi.org".to_owned())); +} diff --git a/infra/values.local.yaml b/infra/values.local.yaml index d54d57eca..8b093ac70 100644 --- a/infra/values.local.yaml +++ b/infra/values.local.yaml @@ -19,6 +19,16 @@ ironProxy: secretSource: env manager: secretSource: env + # `centaur-tools run` executes each tool via `uvx --from`, which resolves the + # tool's deps AND the hatchling build backend from a package index at first run. + # Under iron-control (console.enabled) sandbox egress is deny-by-default — + # union(baseline, granted credentials' hosts) — and a package index has no + # credential, so without this no tool can start: even a zero-dependency tool + # dies fetching hatchling. See docs/archive/notes/2026-07-16-centaur-tool-egress-and-deps.md. + # These are UNIONED with the harness's own entry, so they cannot cut model egress. + extraAllowlistDomains: + - "pypi.org" + - "files.pythonhosted.org" slackbot: enabled: false