Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions centaur/contrib/chart/templates/apirs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions centaur/contrib/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 21 additions & 2 deletions centaur/services/api-rs/crates/centaur-api-server/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1768,6 +1768,19 @@ struct IronProxyArgs {
value_delimiter = ','
)]
upstream_deny_cidrs: Vec<String>,
/// 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<String>,
#[command(flatten)]
ca: IronProxyCaArgs,
#[command(flatten)]
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,6 +16,48 @@ pub fn load_fragment_str(contents: &str) -> Result<ProxyFragment> {
})
}

/// 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<ProxyFragment> {
let domains: Vec<Value> = 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.
Expand Down
5 changes: 3 additions & 2 deletions centaur/services/api-rs/crates/centaur-iron-proxy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions centaur/services/api-rs/crates/centaur-iron-proxy/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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()));
}
10 changes: 10 additions & 0 deletions infra/values.local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading