PRD 0010: Credential proxy for agent-bound API tokens #14

Merged
didericis merged 24 commits from cred-proxy into main 2026-05-24 14:24:52 -04:00
4 changed files with 114 additions and 6 deletions
Showing only changes of commit 51b20340a9 - Show all commits
+32 -1
View File
@@ -18,13 +18,20 @@ from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...log import die, info from ...log import die, info
from ...pipelock import pipelock_build_config, pipelock_render_yaml
from . import network as network_mod from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .cred_proxy import DockerCredProxy from .cred_proxy import DockerCredProxy
from .git_gate import DockerGitGate from .git_gate import DockerGitGate
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init from .pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
DockerPipelockProxy,
pipelock_proxy_url,
pipelock_tls_init,
)
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
@@ -68,6 +75,14 @@ def launch(
egress_network = network_mod.network_create_egress(plan.slug) egress_network = network_mod.network_create_egress(plan.slug)
stack.callback(network_mod.network_remove, egress_network) stack.callback(network_mod.network_remove, egress_network)
# Docker assigns a CIDR to the new internal network. Pipelock's
# SSRF guard otherwise rejects any destination resolving into
# RFC1918 space — which includes the cred-proxy / git-gate /
# pipelock sidecars themselves. Allowlist the bottle's own
# internal subnet so the agent can reach its sidecars via
# pipelock; api_allowlist + body-scanning still apply.
internal_cidr = network_mod.network_inspect_cidr(internal_network)
# Per-bottle ephemeral CA for pipelock's TLS interception # Per-bottle ephemeral CA for pipelock's TLS interception
# (PRD 0006). One-shot pipelock container writes ca.pem + # (PRD 0006). One-shot pipelock container writes ca.pem +
# ca-key.pem under plan.stage_dir; .start docker-cp's them # ca-key.pem under plan.stage_dir; .start docker-cp's them
@@ -75,9 +90,25 @@ def launch(
# stage dir, which start.py's outer finally `shutil.rmtree`s # stage dir, which start.py's outer finally `shutil.rmtree`s
# after the sidecar is torn down. # after the sidecar is torn down.
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir) ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
# Re-render the pipelock yaml with the SSRF allowlist now that
# we know the internal CIDR. Prepare wrote the yaml without
# the ssrf block (CIDR wasn't known yet); overwrite the same
# path so .start docker-cp's the updated content.
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
cfg = pipelock_build_config(
bottle,
ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER,
ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER,
ssrf_ip_allowlist=(internal_cidr,),
)
plan.proxy_plan.yaml_path.write_text(pipelock_render_yaml(cfg))
plan.proxy_plan.yaml_path.chmod(0o600)
proxy_plan = dataclasses.replace( proxy_plan = dataclasses.replace(
plan.proxy_plan, plan.proxy_plan,
internal_network=internal_network, internal_network=internal_network,
internal_network_cidr=internal_cidr,
egress_network=egress_network, egress_network=egress_network,
ca_cert_host_path=ca_cert_host, ca_cert_host_path=ca_cert_host,
ca_key_host_path=ca_key_host, ca_key_host_path=ca_key_host,
+23
View File
@@ -81,6 +81,29 @@ def network_create_egress(slug: str) -> str:
return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False) return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
def network_inspect_cidr(name: str) -> str:
"""Return the IPv4 CIDR Docker assigned to a user-defined network.
Used by pipelock's SSRF guard exception: the bottle's internal
network sits in RFC1918 space, so pipelock's `internal:` list
would block any agent request whose destination resolves there
— including the cred-proxy sidecar's address. Adding the
network's CIDR to pipelock's `ssrf.ip_allowlist` lets traffic
targeted at the bottle's own sidecars through while pipelock
still body-scans and api_allowlist-gates as usual."""
result = subprocess.run(
["docker", "network", "inspect",
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(f"docker network inspect {name} failed: {result.stderr.strip()}")
cidr = result.stdout.strip()
if not cidr:
die(f"network {name!r} has no IPAM subnet configured")
return cidr
def network_attach(network: str, container: str) -> None: def network_attach(network: str, container: str) -> None:
result = subprocess.run( result = subprocess.run(
["docker", "network", "connect", network, container], ["docker", "network", "connect", network, container],
+32 -5
View File
@@ -152,6 +152,7 @@ def pipelock_build_config(
*, *,
ca_cert_path: str = "", ca_cert_path: str = "",
ca_key_path: str = "", ca_key_path: str = "",
ssrf_ip_allowlist: tuple[str, ...] = (),
) -> dict[str, object]: ) -> dict[str, object]:
"""Build the structured pipelock config dict the sidecar will load. """Build the structured pipelock config dict the sidecar will load.
@@ -166,7 +167,17 @@ def pipelock_build_config(
Pass both or neither: both → emit `tls_interception` block with Pass both or neither: both → emit `tls_interception` block with
`enabled: true`; neither → omit the block entirely (pipelock `enabled: true`; neither → omit the block entirely (pipelock
falls back to its built-in default of `enabled: false`). Used falls back to its built-in default of `enabled: false`). Used
by PRD 0006 to turn on pipelock's native TLS interception.""" by PRD 0006 to turn on pipelock's native TLS interception.
`ssrf_ip_allowlist` is the list of IPs / CIDRs that bypass
pipelock's SSRF guard. Pipelock blocks RFC1918-resolved
destinations by default, which would catch the agent's
cred-proxy traffic (cred-proxy sits on the bottle's internal
Docker network in 172.x space). Pass the bottle's internal
network CIDR here so `cred-proxy:9099` requests get through
pipelock while api_allowlist + body-scanning still apply. Empty
by default; omitted from the rendered yaml when empty so
pipelock keeps its built-in SSRF defaults."""
cfg: dict[str, object] = { cfg: dict[str, object] = {
"version": 1, "version": 1,
"mode": "strict", "mode": "strict",
@@ -193,6 +204,8 @@ def pipelock_build_config(
"ca_key": ca_key_path, "ca_key": ca_key_path,
"passthrough_domains": pipelock_effective_tls_passthrough(bottle), "passthrough_domains": pipelock_effective_tls_passthrough(bottle),
} }
if ssrf_ip_allowlist:
cfg["ssrf"] = {"ip_allowlist": list(ssrf_ip_allowlist)}
return cfg return cfg
@@ -236,6 +249,13 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
lines.append(" passthrough_domains:") lines.append(" passthrough_domains:")
for d in passthrough: for d in passthrough:
lines.append(f' - "{d}"') lines.append(f' - "{d}"')
if "ssrf" in cfg:
lines.append("")
lines.append("ssrf:")
ssrf = cast(dict[str, object], cfg["ssrf"])
lines.append(" ip_allowlist:")
for ip in cast(list[str], ssrf["ip_allowlist"]):
lines.append(f' - "{ip}"')
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@@ -252,14 +272,21 @@ class PipelockProxyPlan:
already so it doesn't need the host paths to be valid). The already so it doesn't need the host paths to be valid). The
remaining fields are populated by the backend's launch step remaining fields are populated by the backend's launch step
via `dataclasses.replace`: internal/egress networks once via `dataclasses.replace`: internal/egress networks once
those networks exist, and the CA host paths once the those networks exist, the CA host paths once the one-shot
one-shot `pipelock tls init` has run. Empty defaults are `pipelock tls init` has run, and `internal_network_cidr` once
sentinels meaning "not yet set"; `.start` validates that Docker has assigned a subnet to the internal network. Empty
they are populated.""" defaults are sentinels meaning "not yet set"; `.start` validates
that they are populated.
`internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist`
so the agent's requests at `cred-proxy:9099` (or any other
bottle-internal sidecar) bypass pipelock's RFC1918 SSRF guard
while api_allowlist and body-scanning still apply."""
yaml_path: Path yaml_path: Path
slug: str slug: str
internal_network: str = "" internal_network: str = ""
internal_network_cidr: str = ""
egress_network: str = "" egress_network: str = ""
ca_cert_host_path: Path = Path() ca_cert_host_path: Path = Path()
ca_key_host_path: Path = Path() ca_key_host_path: Path = Path()
+27
View File
@@ -77,6 +77,21 @@ class TestBuildConfig(unittest.TestCase):
ca_cert_path="/etc/pipelock-ca.pem", ca_cert_path="/etc/pipelock-ca.pem",
) )
def test_ssrf_block_omitted_when_no_allowlist(self):
cfg = pipelock_build_config(fixture_minimal().bottles["dev"])
self.assertNotIn("ssrf", cfg)
def test_ssrf_block_emitted_when_allowlist_supplied(self):
# The bottle's internal Docker subnet lands here at launch
# time so cred-proxy:9099 (172.x.x.x) doesn't trip pipelock's
# RFC1918 SSRF guard.
cfg = pipelock_build_config(
fixture_minimal().bottles["dev"],
ssrf_ip_allowlist=("172.20.0.0/16",),
)
self.assertIn("ssrf", cfg)
self.assertEqual({"ip_allowlist": ["172.20.0.0/16"]}, cfg["ssrf"])
class TestRenderAndWrite(unittest.TestCase): class TestRenderAndWrite(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -148,6 +163,18 @@ class TestRenderAndWrite(unittest.TestCase):
self.assertIn("passthrough_domains:", content) self.assertIn("passthrough_domains:", content)
self.assertIn('- "api.anthropic.com"', content) self.assertIn('- "api.anthropic.com"', content)
def test_render_emits_ssrf_block_when_allowlist_given(self):
cfg = pipelock_build_config(
fixture_minimal().bottles["dev"],
ca_cert_path="/etc/pipelock-ca.pem",
ca_key_path="/etc/pipelock-ca-key.pem",
ssrf_ip_allowlist=("172.20.0.0/16",),
)
text = pipelock_render_yaml(cfg)
self.assertIn("ssrf:", text)
self.assertIn("ip_allowlist:", text)
self.assertIn('- "172.20.0.0/16"', text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()