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
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
title: The allowlist names shapes, and a name must resolve where its kind says
status: implemented
date: 2026-10-19
scope: stack
---

## Decision

Three changes to what leaves a probe and what a probe keeps, each one a gap a
second deployment running copies of these files found in use and closed:

1. **`EGRESS_ALLOW` admits two named shapes, `@aws-api` and `@aws-s3`, and the
work compose's default uses them instead of `.amazonaws.com`.** `@aws-api`
is `<service>[.<region>].amazonaws.com`, any service, any region; `@aws-s3`
is a bucket in every spelling. Neither matches an EKS cluster endpoint, an
EC2 instance's public name, a load balancer or an API Gateway stage: the
first label of those is anyone's. A cluster the operator means is one bare
name in the operator's own list. An unknown `@` rule stops the container at
start.
2. **A listed name is vetted at the address.** The proxy resolves the name,
refuses any answer that is loopback, link-local, private, reserved or the
address a desktop container runtime maps the host machine to, and connects to
the address it vetted — never a second lookup. Compose peers are named in
`EGRESS_INTERNAL` and held to the inverse: they must resolve inside the
compose network. The name is checked before any lookup. A name that does not
resolve is a 502, not a 403.
3. **A chat bot's incoming-webhook URL is a credential.** The investigator's
output guard names the kind (`chat webhook url`) and the redactor masks the
token half of Feishu/Lark, Slack, DingTalk, WeCom and Discord hooks while
keeping the host.
4. **Scratch and the CLI's own logs have a retention window of their own**
(`HOOKPROBE_SCRATCH_DAYS`, default 7): the agent subprocess gets
`TMPDIR={workdir}/scratch`, and that directory plus `$HOME/.cache/claude-cli-nodejs`
are pruned daily, whatever `HOOKPROBE_RETENTION_DAYS` says.

## Why

- `.amazonaws.com` was written for the planner's service calls and admitted
every hostname anyone can obtain under that suffix. The sibling verified it
with the matcher itself; the operator's list carried the same rule.
- The list was on names and the connection on addresses, with nothing between
them. In this family's topology the probes have no route, so the risk sat in
the doors container, which does — and that container is the one with the
allowlist in it.
- On 2026-10-12 the sibling's action job read a function's environment and a
chat webhook URL went into the transcript, two logs and the model call.
Nothing in `redact.py` or `_SECRET_SHAPES` named the shape, and this family
posts its own cards through exactly such URLs.
- The sibling found a day-old scratch holding other teams' configs with on-call
numbers. Here the measured accumulation was different and older: the CLI's
MCP transport logs under `home/.cache` had grown since 2026-09-04 on two
nodes, carrying whatever tools returned, with nothing reading them and
`retention.prune` not naming them. Case files stay kept-forever by default
because they are recall; these never were.

## Consequences

- Operators whose `.env` pins `EGRESS_ALLOW` keep what they pinned until they
change it; the work stack's line now carries the two shapes and its cluster
endpoints by name.
- A compose peer reached through the proxy has to be in `EGRESS_INTERNAL`; the
two deploy composes list theirs. A peer name that resolves to the host is
refused, which is the point.
- The redaction keeps the platform host, so a case file still says which chat
was posted to.
- `HOOKPROBE_SCRATCH_DAYS=0` keeps scratch and the logs; the window is per node
like every other knob of its kind.
16 changes: 15 additions & 1 deletion deploy/docker-compose.work.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,21 @@ services:
# its old name so the ledger's history stays one file.
- ../work-data/mcp-gate:/data
environment:
# `@aws-api` is any AWS service API endpoint in any region and `@aws-s3`
# is a bucket (deploy/sidecars/proxy.py, SHAPES). They replaced
# `.amazonaws.com` on 2026-10-19: that suffix also admitted every hostname
# anyone can get under it — an instance's public name, a load balancer, an
# API Gateway stage, somebody else's EKS cluster — a destination an
# attacker controls, inside the allowlist. A cluster endpoint the planner
# is given has an id for its first label, so the shapes do not match it on
# purpose: add it to EGRESS_ALLOW in .env by name, next to these.
EGRESS_ALLOW: >-
${EGRESS_ALLOW:-open.larksuite.com,api.anthropic.com,.amazonaws.com,docs.aws.amazon.com,grafana.com,raw.githubusercontent.com,hookrelay,sink}
${EGRESS_ALLOW:-open.larksuite.com,api.anthropic.com,@aws-api,@aws-s3,docs.aws.amazon.com,grafana.com,raw.githubusercontent.com,hookrelay,sink}
# This compose's own peers reached through the proxy. A listed external
# name must resolve to a public address; a peer named here must resolve
# inside the compose network — a peer name that has fallen through to the
# host's resolver is not the peer, and is refused.
EGRESS_INTERNAL: ${EGRESS_INTERNAL:-hookrelay,sink}
MCPGATE_UPSTREAM: ${WATCH_CHAT_MCP_URL:-http://host.docker.internal:52222/mcp/}
# A second MCP server shares this port as a route: replace the line above
# with one MCPGATE_ROUTE_<NAME>=<url> per server, write each node's list
Expand Down Expand Up @@ -389,6 +402,7 @@ services:
HOOKPROBE_PRICE_CACHE_READ_PER_1M: ${HOOKPROBE_PRICE_CACHE_READ_PER_1M:-}
HOOKPROBE_PRICE_CACHE_WRITE_PER_1M: ${HOOKPROBE_PRICE_CACHE_WRITE_PER_1M:-}
HOOKPROBE_RETENTION_DAYS: ${HOOKPROBE_RETENTION_DAYS:-}
HOOKPROBE_SCRATCH_DAYS: ${HOOKPROBE_SCRATCH_DAYS:-}
# Operator methodology appended to the system prompt, re-read every run —
# edit it and the next run uses it, no restart. That file is how this
# instance becomes a PLANNER with opinions rather than a generic
Expand Down
1 change: 1 addition & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ services:
# Case files and transcripts older than this are pruned daily. 0 keeps
# everything — the case files are the agent's episodic memory.
HOOKPROBE_RETENTION_DAYS: ${HOOKPROBE_RETENTION_DAYS:-}
HOOKPROBE_SCRATCH_DAYS: ${HOOKPROBE_SCRATCH_DAYS:-}
# Skill layers: "project" (distilled, on the volume) is always on;
# add "user" to also load a host library mounted at
# /data/home/.claude/skills. HOOKPROBE_SKILLS pins the session's
Expand Down
192 changes: 171 additions & 21 deletions deploy/sidecars/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,47 @@
that nobody in this repository can audit would be a worse trade than the hole
it closes.

WHAT THE LIST IS MADE OF. Three kinds of rule, comma-separated in EGRESS_ALLOW:
a bare name matches exactly; a leading dot matches the tree under it and the
apex; and two named shapes, `@aws-api` and `@aws-s3`, stand for "an AWS service
API endpoint, any service, any region" and "an S3 bucket" —
`<service>.amazonaws.com`, `<service>.<region>.amazonaws.com`,
`<bucket>.s3[.<region>].amazonaws.com`. The shapes replaced `.amazonaws.com` on
2026-10-19: that suffix also admitted every hostname anyone can get under
amazonaws.com — an EC2 instance's public name, a load balancer, an API Gateway
stage, somebody else's EKS cluster — which is a destination an attacker controls
sitting inside the allowlist. A read-only investigation needs the service APIs
and the buckets; a cluster endpoint it is given is one more bare name in the
operator's list, and the shapes do not match it on purpose (the id in front is
anyone's). Found on a second deployment running a copy of this file, and
verified against this file's own matcher. A shape the proxy does not know stops
the container at start rather than admitting nothing in silence.

WHERE A NAME IS ALLOWED TO RESOLVE. The list is on names; the connection is to
an address, and those are two claims. A listed name that resolves to a loopback,
link-local, private or reserved address — or to the address a desktop container
runtime maps the host machine to — is refused, and the proxy connects to the
address it vetted rather than looking the name up a second time. The names of
this compose's own peers (EGRESS_INTERNAL: the pipe, the sink) are the exception
and the inverse: they MUST resolve to a private or loopback address, because a
peer whose name has fallen through to the host's resolver is not the peer. The
name is checked before any lookup, so a name is not a DNS exfiltration channel.
A name that does not resolve is a 502, not a 403: the agent is told to read 403
as "refused, do not retry", and a resolver hiccup is not that.

WHAT THIS IS NOT. It is not a defence against an adversary with code execution
and patience: the allowlist is on HOSTNAMES, and a name on it that serves
attacker-controlled content is a way out (the model gateway itself takes POST
bodies). It bounds the accident and the opportunist, it makes the attempt
and patience: a name on the list that serves attacker-controlled content is a
way out (the model gateway itself takes POST bodies, and anyone can create an
S3 bucket). It bounds the accident and the opportunist, it makes the attempt
visible, and it turns "anywhere" into "somewhere somebody chose".
"""

from __future__ import annotations

import ipaddress
import logging
import os
import re
import socket
import socketserver
import sys
Expand All @@ -39,30 +69,139 @@
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger("egress")

# Comma-separated. A bare name matches exactly; a leading dot matches any
# subdomain (".amazonaws.com" admits "ec2.ap-southeast-1.amazonaws.com").
# Empty means allow NOTHING, which is the safe direction for a variable that
# failed to interpolate — an empty allowlist is loud within seconds, where an
# empty list meaning "allow all" would be silent forever.
ALLOW = tuple(h.strip().lower() for h in os.environ.get("EGRESS_ALLOW", "").split(",") if h.strip())
_AWS_REGION = r"[a-z]{2}(?:-gov)?-[a-z]+-[0-9]"
# The two shapes an `@` rule may name. Full matches, lower-case hosts.
SHAPES: dict[str, re.Pattern[str]] = {
"@aws-api": re.compile(rf"[a-z0-9][a-z0-9-]*(?:\.{_AWS_REGION})?\.amazonaws\.com"),
"@aws-s3": re.compile(rf"[a-z0-9][a-z0-9.-]*\.s3(?:\.dualstack)?(?:[.-]{_AWS_REGION})?\.amazonaws\.com"),
}


def _rules(text: str) -> tuple[str, ...]:
return tuple(h.strip().lower().rstrip(".") for h in text.split(",") if h.strip())


# Comma-separated. Empty means allow NOTHING, which is the safe direction for a
# variable that failed to interpolate — an empty allowlist is loud within
# seconds, where an empty list meaning "allow all" would be silent forever.
ALLOW = _rules(os.environ.get("EGRESS_ALLOW", ""))
# This compose's own peers, by name: admitted without being on ALLOW, and held
# to resolving inside the compose network.
INTERNAL = frozenset(_rules(os.environ.get("EGRESS_INTERNAL", "")))
PORT = int(os.environ.get("EGRESS_PORT", "8888"))
# Ports a client may CONNECT to. Everything the family speaks is HTTP or HTTPS;
# a proxy that will open 22 or 5432 for you is a tunnel, not a boundary.
PORTS = frozenset(int(p) for p in os.environ.get("EGRESS_PORTS", "80,443,8088,8100,8200,4318").split(",") if p.strip())
IDLE_SECONDS = 300
CONNECT_TIMEOUT = 20
# Where a desktop container runtime maps the host machine, inside a range that
# is otherwise "this network" and that the stdlib does not call private.
HOST_MAPPED = ipaddress.ip_network("0.250.250.0/24")
# Where a compose peer may resolve: the bridge networks' private ranges, and
# loopback for the one case of a peer that is this container itself.
INTERNAL_NETS = tuple(
ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128")
)

for _rule in ALLOW:
if _rule.startswith("@") and _rule not in SHAPES:
sys.exit(f"egress: EGRESS_ALLOW names a shape this proxy does not know: {_rule} (known: {', '.join(SHAPES)})")


def permitted(host: str) -> bool:
host = host.lower().strip(".")
if not host:
return False
for rule in ALLOW:
if rule.startswith("."):
if rule.startswith("@"):
if SHAPES[rule].fullmatch(host):
return True
elif rule.startswith("."):
if host == rule[1:] or host.endswith(rule):
return True
elif host == rule:
return True
return False


def internal(host: str) -> bool:
return host.lower().strip(".") in INTERNAL


class Refused(Exception):
"""A destination this proxy will not connect to, with the status the client gets."""

def __init__(self, code: int, why: str) -> None:
super().__init__(why)
self.code, self.why = code, why


def _unwrapped(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
"""An IPv4 address carried inside IPv6 is the IPv4 address."""
return ip.ipv4_mapped if ip.version == 6 and ip.ipv4_mapped is not None else ip


def public(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
candidate = _unwrapped(ip)
if candidate.version == 4 and candidate in HOST_MAPPED:
return False
return candidate.is_global


def inside_compose(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
candidate = _unwrapped(ip)
return any(candidate in net for net in INTERNAL_NETS)


def resolve(host: str, port: int, kind: str) -> list[tuple]:
"""Every address the name gives, each vetted for where that kind of name may
point: `external` must be public, `internal` must be inside the compose."""
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise Refused(502, f"did not resolve: {exc}") from None
vetted: list[tuple] = []
for family, type_, proto, _canon, sockaddr in infos:
ip = ipaddress.ip_address(sockaddr[0].split("%", 1)[0])
if kind == "internal" and not inside_compose(ip):
raise Refused(403, f"a compose peer resolved outside the compose network ({ip})")
if kind == "external" and not public(ip):
raise Refused(403, f"resolves to a non-public address ({ip})")
vetted.append((family, type_, proto, sockaddr))
if not vetted:
raise Refused(502, "did not resolve")
return vetted


def decide(host: str, port: int) -> list[tuple]:
"""Name first, address second. The list is consulted before any lookup, then
every address the name resolves to is vetted; what comes back is what
`connect` dials, with no second lookup in between."""
host = host.lower().strip(".")
if port not in PORTS:
raise Refused(403, f"port {port} is not one this proxy opens")
if internal(host):
return resolve(host, port, "internal")
if not permitted(host):
raise Refused(403, "not on the egress allowlist")
return resolve(host, port, "external")


def connect(infos: list[tuple]) -> socket.socket:
"""A socket to the first vetted address that answers."""
error: OSError | None = None
for family, type_, proto, sockaddr in infos:
sock = socket.socket(family, type_, proto)
sock.settimeout(CONNECT_TIMEOUT)
try:
sock.connect(sockaddr)
return sock
except OSError as exc:
error = exc
sock.close()
raise error or OSError("no address to connect to")


class Handler(socketserver.StreamRequestHandler):
timeout = 30
# Unbuffered reads. The default `rfile` is a buffered reader, and its first
Expand Down Expand Up @@ -102,14 +241,13 @@ def handle(self) -> None:
self._refuse(400, "no port in the CONNECT target", target[:80])
return
host = host.strip("[]")
if port not in PORTS:
self._refuse(403, f"port {port} is not one this proxy opens", target[:80])
return
if not permitted(host):
self._refuse(403, "not on the egress allowlist", target[:80])
try:
infos = decide(host, port)
except Refused as refused:
self._refuse(refused.code, refused.why, target[:80])
return
try:
upstream = socket.create_connection((host, port), timeout=20)
upstream = connect(infos)
except OSError as exc:
self._refuse(502, f"upstream refused: {exc}", target[:80], level=logging.WARNING)
return
Expand Down Expand Up @@ -141,11 +279,14 @@ def _plain(self, parts: list[str]) -> None:
except ValueError:
self._refuse(400, "unreadable port", authority[:80])
return
if port not in PORTS or not permitted(host):
self._refuse(403, "not on the egress allowlist", f"{host}:{port}")
host = host.strip("[]")
try:
infos = decide(host, port)
except Refused as refused:
self._refuse(refused.code, refused.why, f"{host}:{port}")
return
try:
upstream = socket.create_connection((host, port), timeout=20)
upstream = connect(infos)
except OSError as exc:
self._refuse(502, f"upstream refused: {exc}", f"{host}:{port}")
return
Expand Down Expand Up @@ -173,8 +314,9 @@ def _refuse(self, code: int, why: str, target: str, level: int = logging.WARNING
# whether it was a mistake, a dependency nobody declared, or worse.
logger.log(level, "REFUSED %s — %s", target, why)
body = f"{why}\n".encode()
reason = {400: "Bad Request", 403: "Forbidden", 502: "Bad Gateway"}.get(code, "Forbidden")
try:
self.wfile.write(f"HTTP/1.1 {code} Forbidden\r\nContent-Length: {len(body)}\r\n\r\n".encode() + body)
self.wfile.write(f"HTTP/1.1 {code} {reason}\r\nContent-Length: {len(body)}\r\n\r\n".encode() + body)
self.wfile.flush()
except OSError:
pass
Expand All @@ -185,9 +327,17 @@ def build() -> Server:
and the ingress in one process; main() runs it alone."""
if not ALLOW:
logger.warning("EGRESS_ALLOW is empty — every destination will be refused")
logger.info("egress proxy on :%s, %s host rule(s), ports %s", PORT, len(ALLOW), sorted(PORTS))
logger.info(
"egress proxy on :%s, %s host rule(s), %s compose peer(s), ports %s",
PORT,
len(ALLOW),
len(INTERNAL),
sorted(PORTS),
)
for rule in ALLOW:
logger.info(" allow %s", rule)
for name in sorted(INTERNAL):
logger.info(" peer %s (must resolve inside the compose network)", name)
return Server(("0.0.0.0", PORT), Handler) # nosec B104 — internal network only


Expand Down
Loading
Loading