ExSandbox.Egress.Policy (ExSandbox v1.0.0)

Copy Markdown View Source

Which destinations one sandbox may reach, and how that sandbox is identified (005 T060a1/T060a2, contracts/egress.md, 005-FR-011aFR-011e).

Identity is the source address, and it is unforgeable

A sandbox's policy is keyed by the source /30 its packets arrive from. That address is the kernel's account of which network namespace a connection originated in — :inet.peername/1 on the accepted socket, not anything the connecting party said.

This matters more than it looks. Tenant code cannot claim another sandbox's allowlist, because it never asserts an identity at all: there is no token to steal, no header to spoof, and no name to collide with. The strongest form of an authorization check is one where the subject cannot participate in answering it.

⚠️ That property depends on no sandbox having a route to any other (005-FR-011c, 003-FR-002). If two sandboxes shared a link, one could originate a connection from within the other's /30 and inherit its policy. This is why ExSandbox.SharedRouteMechanism exists as an adversary: it is conformant in every outward-facing respect and fails only that, so a check that trusts topology instead of attempting a crossing shows up as a false pass.

Default deny

permits?/2 answers false for a /30 with no entry. A missing policy is not an absent restriction — it is the most restrictive one. FR-011a requires an allowlist over default-deny, and the ordering is the whole guarantee: a lookup miss must never be the path by which something becomes reachable.

Summary

Types

A permitted destination. :any_port admits a host on every port; a specific port admits only that one.

An IPv4 address as :inet reports it.

What one sandbox resolved, per name (029-FR-012).

The /30 a sandbox's connections originate from, as {a, b, c, d}.

Functions

The canonical form of a DNS name for comparison: lower-cased, with any trailing root dot removed.

True only when destination is explicitly permitted for source.

Reduces a source address to the /30 it belongs to.

Types

destination()

@type destination() :: {ip() | String.t(), :inet.port_number() | :any_port}

A permitted destination. :any_port admits a host on every port; a specific port admits only that one.

ip()

@type ip() :: :inet.ip4_address()

An IPv4 address as :inet reports it.

resolutions()

@type resolutions() :: %{optional(String.t()) => MapSet.t(:inet.ip_address())}

What one sandbox resolved, per name (029-FR-012).

⚠️ Per sandbox, never global. A shared table would let one tenant's resolution of a name decide another tenant's verdict for it.

source_key()

@type source_key() :: ip()

The /30 a sandbox's connections originate from, as {a, b, c, d}.

Functions

normalise_name(name)

@spec normalise_name(String.t()) :: String.t()

The canonical form of a DNS name for comparison: lower-cased, with any trailing root dot removed.

Public because the recording side and the matching side must agree exactly, and two copies of this would be two things that must stay equal forever. The symptom of them drifting is a permitted host that is silently refused, which reads as an unreachable network.

permits?(allowed, destination, resolutions \\ %{})

@spec permits?(
  [destination()],
  {ip() | String.t(), :inet.port_number()},
  resolutions()
) :: boolean()

True only when destination is explicitly permitted for source.

Returns false for an unknown source — see the default-deny note above.

resolutions is what this sandbox resolved, as ExSandbox.Egress.Registry.resolutions/2 returns it: %{name => MapSet of addresses}. It is what makes a hostname entry able to match at all (029-FR-012); omitted, it defaults to %{} and no hostname entry matches anything, which is the pre-029 behaviour and is default-deny.

source_key(arg)

@spec source_key(ip()) :: source_key()

Reduces a source address to the /30 it belongs to.

⚠️ Masking is what makes the key stable. A sandbox's connections come from a host address inside its /30, and that address is not necessarily the network address — so keying on the raw source would miss. Masking to the /30 answers "which sandbox is this?" rather than "which address is this?".