# Secrets & Egress: iron-proxy as the Trust Boundary

> How iron-proxy is the single egress choke point for every sandbox, why it is per-sandbox rather than shared (a compromised pod cannot leak into another), the four secret transform types (replace, inject, gcp_auth, oauth_token, hmac_sign), the NetworkPolicy default-deny invariant, and what changes break the security model (shared proxy, raw key injection, relaxed NetworkPolicy).

- Repository: paradigmxyz/centaur
- GitHub: https://github.com/paradigmxyz/centaur
- Human wiki: https://grok-wiki.com/public/wiki/paradigmxyz-centaur-57fc6b2755e2
- Complete Markdown: https://grok-wiki.com/public/wiki/paradigmxyz-centaur-57fc6b2755e2/llms-full.txt

## Source Files

- `services/api/api/proxy_config.py`
- `services/api/api/iron-proxy.base.yaml`
- `services/api/api/tool_manager.py`
- `docs/pages/security.mdx`
- `services/api/tests/test_proxy_config.py`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [services/api/api/proxy_config.py](services/api/api/proxy_config.py)
- [services/api/api/iron-proxy.base.yaml](services/api/api/iron-proxy.base.yaml)
- [services/api/api/tool_manager.py](services/api/api/tool_manager.py)
- [docs/pages/security.mdx](docs/pages/security.mdx)
- [services/api/tests/test_proxy_config.py](services/api/tests/test_proxy_config.py)
- [contrib/chart/templates/networkpolicy.yaml](contrib/chart/templates/networkpolicy.yaml)
</details>

# Secrets & Egress: iron-proxy as the Trust Boundary

Every Centaur sandbox runs untrusted, model-generated code. That code must be able to call external APIs — but it must never hold the real credentials to do so, and it must not be able to send traffic to arbitrary destinations. iron-proxy is the architectural mechanism that satisfies both requirements simultaneously: it is the single egress choke point through which all sandbox network traffic flows, and it is the only component that ever holds or resolves real secret values.

This page explains how iron-proxy is positioned in the network topology, why each sandbox gets its own dedicated proxy instance, how the four managed secret transform types work, what the NetworkPolicy default-deny invariant enforces, and which changes would silently break the model.

---

## The Egress Choke Point

Sandbox pods do not have a direct route to the internet. All outbound HTTP/HTTPS traffic is routed through iron-proxy, which acts as a TLS-terminating MITM (man-in-the-middle) proxy. The base config confirms the proxy operates in `mitm` mode with its own CA certificate:

```yaml
# services/api/api/iron-proxy.base.yaml:14-16
tls:
  mode: "mitm"
  ca_cert: "/etc/iron-proxy/ca.crt"
  ca_key: "/etc/iron-proxy/ca.key"
```

The proxy also runs an embedded DNS server (`:53`) alongside the HTTP tunnel listener (`:8080`) and a management API (`:9092`). All DNS lookups from the sandbox resolve through iron-proxy's DNS forwarder, meaning the proxy is in the path of every connection, not just those targeted at known hosts.

Sources: [services/api/api/iron-proxy.base.yaml:1-16]()

---

## Per-Sandbox Isolation

A single shared proxy would be a cross-contamination risk: a compromised sandbox could observe or inject traffic from other sandboxes sharing the same proxy. Centaur avoids this by giving every sandbox its own iron-proxy pod.

The security documentation makes this explicit:

> Because iron-proxy is per-sandbox rather than shared, a compromise of one sandbox's proxy cannot leak into another sandbox.

Sources: [docs/pages/security.mdx:47-51]()

Each iron-proxy pod carries two labels that uniquely identify its scope:

- `centaur.ai/iron-proxy: "true"` — marks it as a proxy pod
- `centaur.ai/sandbox-id: <id>` — ties it to exactly one sandbox

The NetworkPolicy for the API-side proxy selects pods with both labels, meaning only the API process and its associated proxy can communicate with each other on the proxy and Postgres listener ports.

Sources: [contrib/chart/templates/networkpolicy.yaml:300-346]()

---

## The NetworkPolicy Default-Deny Invariant

The Helm chart establishes a namespace-wide default-deny policy that blocks all ingress and egress for every pod in the namespace:

```yaml
# contrib/chart/templates/networkpolicy.yaml:9-12
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
```

`podSelector: {}` selects all pods. Without an additive allow rule, no pod can send or receive any traffic. Subsequent `NetworkPolicy` objects in the same file then grant the minimum set of permissions for each component:

| Component | Permitted Egress | Permitted Ingress |
|---|---|---|
| Sandbox pod (`centaur.ai/managed: "true"`) | API pod port 8000 only | (none defined) |
| API pod | Postgres, slackbot, its own iron-proxy, port 443 | slackbot, iron-proxy, sandbox pods, allowed CIDRs |
| iron-proxy (API-scoped) | TCP 80, 443, 5432 | API pod (proxy port + pg port range) |
| All pods | kube-system UDP/TCP 53 (DNS) | — |

Sandbox pods are labeled `centaur.ai/managed: "true"` and their NetworkPolicy permits egress only to the API pod on port 8000. The sandbox communicates with its iron-proxy through the API's own proxy listener — the sandbox does not hold the proxy's address directly.

Sources: [contrib/chart/templates/networkpolicy.yaml:1-14, 400-420]()

---

## How the API Owns iron-proxy Configuration

The API server is the single authority that produces iron-proxy's runtime config. `proxy_config.py` describes this clearly:

> Centralizes what was previously split between firewall-manager (rendering) and tool_manager (injection map). The API server owns iron-proxy's full config.

`render_proxy_yaml()` takes the list of all declared `SecretDef` objects, splices the managed transforms into the base YAML (inserted before `header_allowlist`), and returns the final iron-proxy config string. The base config provides the allowlist, header allowlist, TLS, DNS, management, and log settings; the API injects all secret-handling transforms at render time.

```python
# services/api/api/proxy_config.py:392-438 (abridged)
def render_proxy_yaml(secrets, base_config=None, *, pg_listen_ports=None):
    cfg = yaml.safe_load(base_config) or {}
    # Strip any previously managed transforms, then rebuild them:
    transforms = [t for t in (cfg.get("transforms") or [])
                  if (t or {}).get("name") not in _MANAGED_TRANSFORMS]
    new_transforms = [...]  # built from secrets
    # Insert before header_allowlist
    cfg["transforms"] = transforms
    ...
    return yaml.safe_dump(cfg, sort_keys=False)
```

The four managed transform names are tracked in `_MANAGED_TRANSFORMS`:

```python
# services/api/api/proxy_config.py:53-55
_MANAGED_TRANSFORMS: frozenset[str] = frozenset(
    {"secrets", "gcp_auth", "oauth_token", "hmac_sign"}
)
```

Sources: [services/api/api/proxy_config.py:1-17, 53-55, 392-438]()

---

## The Four Secret Transform Types

### 1. `secrets` — Replace and Inject Modes

`HttpSecret` is the general-purpose HTTP credential transform. It has two modes:

**Replace mode** (default): The tool writes a placeholder token (the `replacer`) into its request — in a header, query string, or path. iron-proxy scans the configured locations and swaps the placeholder for the resolved credential. The credential value never enters the sandbox.

```python
# services/api/api/tool_manager.py:65-73
class SecretMode(str, Enum):
    REPLACE = "replace"  # tool writes placeholder; proxy swaps it
    INJECT = "inject"    # proxy adds credential; tool never sees it
```

**Inject mode**: iron-proxy adds the credential to the request entirely by itself — the tool emits no placeholder and never observes the value. Supports `inject_header` (with an optional Go-template `inject_formatter` such as `Bearer {{ .Value }}`) or `inject_query_param`.

Each `HttpSecret` entry carries a `hosts` list that becomes `rules` in the rendered config. iron-proxy will only substitute or inject the credential for those specific hosts. A leaked placeholder cannot be redirected to an attacker-controlled host, and it cannot be smuggled out through a different field.

```yaml
# Rendered replace-mode entry (env source)
- source: {type: env, var: OPENAI_API_KEY}
  replace:
    proxy_value: OPENAI_API_KEY
    match_headers: [Authorization]
  rules:
    - host: api.openai.com
```

Sources: [services/api/api/proxy_config.py:101-156](), [services/api/api/tool_manager.py:65-122](), [services/api/tests/test_proxy_config.py:807-878]()

---

### 2. `gcp_auth` — GCP Service Account Token Injection

`GcpAuthSecret` feeds a Google service-account keyfile to iron-proxy. iron-proxy loads the keyfile, mints OAuth2 bearer tokens for the configured scopes, and injects them as `Authorization: Bearer` on matching upstreams. The sandbox never receives the keyfile or the minted token.

Multiple GCP service accounts can coexist. Each unique keyfile (`secret_ref`) gets its own `gcp_auth` transform; secrets sharing a `secret_ref` are merged into one transform with the union of their hosts and scopes.

Default values apply when left unset: `*.googleapis.com` for hosts, `https://www.googleapis.com/auth/cloud-platform` for scopes.

```python
# services/api/api/proxy_config.py:48-49
GCP_AUTH_SCOPES: tuple[str, ...] = ("https://www.googleapis.com/auth/cloud-platform",)
GCP_AUTH_HOSTS: tuple[str, ...] = ("*.googleapis.com",)
```

> Superseded by `oauth_token`; kept until tools migrate off the `gcp_auth` secret type.

Sources: [services/api/api/proxy_config.py:159-193](), [services/api/api/tool_manager.py:124-145]()

---

### 3. `oauth_token` — Generic OAuth2 Token Exchange

`OAuthTokenSecret` generalizes the GCP token-minting pattern to any OAuth2 provider. iron-proxy resolves each named credential field from its own secret source, runs the configured grant exchange, caches and refreshes the resulting access token, and injects it as `Authorization: Bearer` on matching hosts.

Four grant types are supported:

| Grant | RFC | Required fields |
|---|---|---|
| `refresh_token` | RFC 6749 | `refresh_token`, `client_id` (+ optional `client_secret`) |
| `client_credentials` | RFC 6749 §4.4 | `client_id`, `client_secret` |
| `password` | RFC 6749 §4.3 | `username`, `password`, `client_id` (+ optional `client_secret`) |
| `jwt_bearer` | RFC 7523 | `issuer`, `subject`, `private_key`, `audience` (+ optional `private_key_id`) |

Credential fields can be sourced from separate secrets or extracted from a single JSON-encoded secret via `json_key`. Optional `token_endpoint_headers` allows extra headers on the token POST itself — useful when the token endpoint requires an API key alongside form-body client auth.

Multiple `OAuthTokenSecret` entries that resolve to the same token (same grant, credential fields, and token endpoint) are merged, unioning their hosts and scopes into a single `tokens` entry.

Sources: [services/api/api/proxy_config.py:208-285](), [services/api/api/tool_manager.py:179-213](), [services/api/tests/test_proxy_config.py:970-1010]()

---

### 4. `hmac_sign` — Per-Request HMAC Signature

`HmacSignSecret` implements exchange-style HMAC authentication used by trading APIs such as FalconX. iron-proxy resolves all credentials, composes the canonical message template, computes the HMAC digest, and writes the configured request headers. The signing key and all other credentials never reach the sandbox.

Key parameters:

| Parameter | Allowed values |
|---|---|
| `algorithm` | `sha256`, `sha512`, `sha1` |
| `key_encoding` | `raw`, `base64`, `hex` |
| `output_encoding` | `base64`, `hex` |
| `timestamp_format` | `unix_seconds`, `unix_millis`, `unix_nanos`, `rfc3339` |

The `message` field is a Go template with access to `.Timestamp`, `.Method`, `.PathWithQuery`, and `.Body`. Headers are Go templates with access to `.Signature`, `.Timestamp`, and `.Credentials.<name>`. By default, chunked-body requests are refused (body cannot be deterministically hashed in flight); `allow_chunked_body: true` opts in.

```python
# services/api/api/tool_manager.py:289-297 (enum constants)
_HMAC_ALGORITHMS = frozenset({"sha256", "sha512", "sha1"})
_HMAC_KEY_ENCODINGS = frozenset({"raw", "base64", "hex"})
_HMAC_OUTPUT_ENCODINGS = frozenset({"base64", "hex"})
_HMAC_TIMESTAMP_FORMATS = frozenset({"unix_seconds", "unix_millis", "unix_nanos", "rfc3339"})
```

Sources: [services/api/api/proxy_config.py:288-356](), [services/api/api/tool_manager.py:229-257](), [services/api/tests/test_proxy_config.py:1337-1369]()

---

## Secret Source Backends

All four transform types share the same secret-source abstraction. The `FIREWALL_MANAGER_SECRET_SOURCE` environment variable controls where iron-proxy resolves credential values:

| Value | iron-proxy source type | Secret reference format |
|---|---|---|
| `env` (default) | `env` | Environment variable name |
| `onepassword` | `1password` | `op://<vault>/<ref>/credential` |
| `onepassword-connect` | `1password_connect` | `op://<vault>/<ref>/credential` |

The vault name is read from `OP_VAULT` (default: `ai-agents`). Secret TTL for cached values defaults to `10m` (configurable via `FIREWALL_MANAGER_SECRET_TTL`).

```python
# services/api/api/proxy_config.py:79-88
def _build_source(secret_ref: str) -> dict[str, str]:
    iron_proxy_type = _OP_REF_SOURCES.get(_secret_source_kind())
    if iron_proxy_type is not None:
        return {"type": iron_proxy_type,
                "secret_ref": f"op://{_op_vault()}/{secret_ref}/credential",
                "ttl": _secret_ttl()}
    return {"type": "env", "var": secret_ref}
```

Sources: [services/api/api/proxy_config.py:56-88]()

---

## Egress Domain Allowlist

The base config ships with a permissive `allowlist` transform that allows all domains:

```yaml
# services/api/api/iron-proxy.base.yaml:17-21
transforms:
  - name: allowlist
    config:
      domains:
        - "*"
```

This is a deliberate UX trade-off: operators can start without configuring an allowlist and tighten it later. To lock egress down, replace `"*"` with explicit hostname patterns (e.g., `*.anthropic.com`). iron-proxy rejects unlisted destinations with a 403.

The `header_allowlist` transform further constrains which request headers sandbox code can send. Headers not on the allowlist are stripped before forwarding. Notably, the header allowlist includes a regex pattern for common auth headers (`/^x-[a-z0-9-]*(api-key|apikey|secret|token|auth|key)$/`), reflecting the range of API credential header names tools may use.

Sources: [services/api/api/iron-proxy.base.yaml:17-78](), [docs/pages/security.mdx:58-74]()

---

## Infrastructure Secrets

Beyond tool-declared secrets, the API server registers a set of hardcoded infrastructure secrets covering the LLM API keys and platform credentials that the harness itself uses:

```python
# services/api/api/tool_manager.py:1594-1637 (abridged)
_INFRA_SECRETS: ClassVar[list[HttpSecret]] = [
    HttpSecret("ANTHROPIC_API_KEY", ..., hosts=("api.anthropic.com",), match_headers=("X-Api-Key",)),
    HttpSecret("OPENAI_API_KEY",    ..., hosts=("api.openai.com",),    match_headers=("Authorization",)),
    HttpSecret("XAI_API_KEY",       ..., hosts=("api.x.ai",),          match_headers=("Authorization",)),
    HttpSecret("GEMINI_API_KEY",    ..., hosts=("generativelanguage.googleapis.com",), match_headers=("X-Goog-Api-Key",)),
    HttpSecret("GITHUB_TOKEN",      ..., hosts=("github.com", "api.github.com"), ...),
    HttpSecret("SLACK_BOT_TOKEN",   ..., hosts=("*.slack.com",), ...),
    ...
]
```

`collect_secrets()` merges infrastructure secrets with tool-declared secrets before rendering:

```python
# services/api/api/tool_manager.py:1639-1648
def collect_secrets(self) -> list[SecretDef]:
    out: list[SecretDef] = list(self._INFRA_SECRETS)
    for lt in self.tools.values():
        out.extend(lt.all_secrets)
    return out
```

Sources: [services/api/api/tool_manager.py:1592-1648]()

---

## What Tools See vs. What iron-proxy Sees

The distinction between what is visible inside the sandbox and what is visible only to iron-proxy is a core invariant of the design:

| Secret type | What sandbox tool code sees | What iron-proxy holds |
|---|---|---|
| `http` (replace) | Placeholder token (e.g. `WAREHOUSE_API_KEY`) | Resolved credential value |
| `http` (inject) | Nothing | Resolved credential value |
| `gcp_auth` | Nothing | GCP keyfile; minted token |
| `oauth_token` | Nothing | Credential fields; minted access token |
| `hmac_sign` | Nothing | Signing key; computed HMAC digest |
| `pg_dsn` | Local DSN (`localhost:5432`) | Real upstream DSN |

The sandbox receives placeholder strings via `ToolContext.secrets` — only for replace-mode `HttpSecret`. All other secret types are applied entirely on the wire:

```python
# services/api/api/tool_manager.py:838-850
async def _resolve_secrets(secrets: list[SecretDef]) -> dict[str, str]:
    """Only replace-mode HttpSecret entries end up in the tool's ToolContext.
    Inject-mode HTTP secrets are applied entirely by iron-proxy.
    GcpAuthSecret, OAuthTokenSecret and PgDsnSecret are likewise not
    exposed via context."""
    return {s.name: s.replacer for s in secrets if _is_replace_secret(s)}
```

Sources: [services/api/api/tool_manager.py:838-850](), [docs/pages/security.mdx:82-114]()

---

## What Breaks the Security Model

The following changes each remove a different layer of the trust boundary:

### Shared proxy (one proxy for multiple sandboxes)
A single iron-proxy instance serving multiple sandboxes defeats per-sandbox isolation. If a sandbox can compromise its proxy (through a vulnerability in iron-proxy or in a transform), it gains visibility into every other sandbox's traffic and resolved credentials. The NetworkPolicy uses per-sandbox `centaur.ai/sandbox-id` labels specifically to prevent lateral movement between proxy pods.

### Raw key injection into sandbox environment
If real credential values are placed in sandbox environment variables, files, or the tool's `ToolContext.secrets`, they can be exfiltrated through logs, error messages, tool return values, or direct prompt injection. The entire point of the replace/inject model is that the sandbox holds only placeholder tokens that are meaningless outside the proxy.

### Relaxed NetworkPolicy
If the default-deny policy is removed or if sandbox pods receive egress beyond the API pod, they can bypass iron-proxy entirely by opening direct connections to external hosts. The default-deny-then-additive-allow structure means any misconfiguration defaults to blocked, not open. Removing the `centaur-default-deny` `NetworkPolicy` object would silently grant all pods full network access.

### Relaxed allowlist (`domains: ["*"]` left in production)
The default permissive allowlist means a prompt-injection attack can redirect a tool to an attacker-controlled host. The placeholder substitution is host-scoped — a credential will only be injected for matching hosts — but a separate secret exfiltration channel (sending arbitrary data via `fetch()` in the sandbox) would succeed against any reachable host. Locking the allowlist to the exact set of hosts each tool needs is the primary control against data exfiltration.

Sources: [docs/pages/security.mdx:47-51, 58-74, 130-156](), [contrib/chart/templates/networkpolicy.yaml:1-14]()

---

## Summary

iron-proxy is the singular outbound channel for every sandbox and the sole holder of resolved secret values. Its security properties depend on four reinforcing invariants: (1) per-sandbox deployment so a compromised proxy cannot bleed across sandbox boundaries, (2) NetworkPolicy default-deny enforced at the Kubernetes level so sandbox code cannot open connections that bypass the proxy, (3) the placeholder-not-value model so credentials cannot be read from the sandbox's environment or logs, and (4) host-scoped rules so every credential substitution is bound to the specific upstream that legitimately needs it. Audit and structured logging from iron-proxy and the agent execution record together provide the evidence trail needed to reconstruct what any agent did and which credentials it reached for. ([docs/pages/security.mdx:119-124]())
