From 209b813c86cdd998e7b1568cab3b0869451ca934 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:49:19 +0100 Subject: [PATCH] feat(egress): draft EgressPolicy schema + decide/3 (audit #31, P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a deny-by-default outbound policy module so estate apps can route cloud/LLM calls *through* the gateway and have the same declarative policy bound what leaves the box. Needed by hyperpolymath/neurophone for its data-egress obligation (#84-3.1). Scope (DRAFT — small intentional surface): * HttpCapabilityGateway.EgressPolicy with validate/1 + decide/3 * Host+verb allowlist with optional capability + classification labels * Host comparison is exact-string lowercased; no wildcard matching by design (explicit listing is safer for egress) * 87 LoC of tests covering validate happy path, all four error shapes, and decide allow/deny on listed/unlisted host+verb pairs NOT in scope (follow-ups): * Actual outbound forwarder (Proxy.egress/2) — this PR is policy + schema * Wiring into PolicyLoader / Application start * chimichanga-style capability attenuation on the matched entry Echo-types audit: record-as-not-relevant for this PR (see PROOF-NEEDS.md). Refs: #31 (self-audit, priority 1) --- PROOF-NEEDS.md | 40 +++++ lib/http_capability_gateway/egress_policy.ex | 166 +++++++++++++++++++ test/egress_policy_test.exs | 87 ++++++++++ 3 files changed, 293 insertions(+) create mode 100644 PROOF-NEEDS.md create mode 100644 lib/http_capability_gateway/egress_policy.ex create mode 100644 test/egress_policy_test.exs diff --git a/PROOF-NEEDS.md b/PROOF-NEEDS.md new file mode 100644 index 0000000..45ab238 --- /dev/null +++ b/PROOF-NEEDS.md @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell + +# Proof Needs (echo-types audit) + +This file records cross-repo proof obligations relevant to this repository, +including an explicit echo-types audit per estate convention +(`feedback_proofs_must_check_and_cross_doc_echo_types.md`). + +## Echo-types + +**Status: record-as-not-relevant (2026-06-02).** + +`hyperpolymath/echo-types` was audited for relevant existing proofs that +this repository should reuse or extend. Findings: + +- The trust hierarchy already has a mechanised proof in + `proven/SafeTrust.idr` (referenced from `lib/http_capability_gateway/safe_trust.ex`). +- No L3 (echo) obligation is in scope for the gateway: the gateway does not + participate in any echo protocol; it terminates inbound HTTP and forwards + to a single backend. +- The new `EgressPolicy` module's decision function is a pure first-order + predicate (host + verb membership in an allowlist). It does not interact + with echo types. + +**Action:** none. Re-check this entry when egress acquires a +chimichanga-attenuation seam (#84-3 follow-up); attenuation may bring an +L3 obligation into scope at that point. + +## SafeTrust monotonicity + +Already mechanised in `proven/SafeTrust.idr` (Idris2). The Elixir +implementation in `lib/http_capability_gateway/safe_trust.ex` mirrors the +specification one-to-one. No new proof debt added by this PR. + +## EgressPolicy + +The egress policy decision function `EgressPolicy.decide/3` is small enough +to mechanise (membership in a list of host+verb pairs). Filing as proof +debt for a future PR; not blocking egress scaffold landing. diff --git a/lib/http_capability_gateway/egress_policy.ex b/lib/http_capability_gateway/egress_policy.ex new file mode 100644 index 0000000..95b71d3 --- /dev/null +++ b/lib/http_capability_gateway/egress_policy.ex @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule HttpCapabilityGateway.EgressPolicy do + @moduledoc """ + Declarative *outbound* (egress) policy schema for HTTP Capability Gateway. + + Companion to the existing inbound `PolicyValidator` / `PolicyCompiler` pair. + Where the inbound pipeline asks "may this client invoke this verb on this + path of our backend?", the egress pipeline asks "may this estate + application emit this verb to that *external* host?". + + This is the contract surface needed by `hyperpolymath/neurophone` (#84-3.1) + to bound what sensor-derived data leaves the device on cloud/LLM calls. + + ## Status + + Scaffold only. This module defines the schema, validates it, and answers + `decide/3`. The actual outbound forwarder (`Proxy.egress/2`) is a separate + follow-up — opening it as a thin seam here lets policy authoring start + immediately without committing to a particular HTTP client. + + ## Schema (DSL v1, egress section) + + egress: + # Deny-by-default. An empty allow list denies ALL outbound traffic. + default: deny + # Allow-list of egress destinations. Each entry binds a host + verb + # set + capability label. The capability label is purely informational + # for audit purposes today, but is the seam where chimichanga-style + # attenuation will attach. + allow: + - host: "api.anthropic.com" + verbs: [POST] + capability: "llm:complete" + classification: "redacted-sensor-summary" + + - host: "api.openai.com" + verbs: [POST] + capability: "llm:complete" + classification: "redacted-sensor-summary" + + Hosts are matched by exact lowercase string. Wildcard / suffix matching is + deliberately NOT supported in v1 — explicit listing is safer for an + egress allowlist and is what neurophone's threat model wants. + """ + + require Logger + + @valid_verbs ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] + + defmodule Entry do + @moduledoc "One allow-list entry in an egress policy." + @enforce_keys [:host, :verbs] + defstruct [:host, :verbs, :capability, :classification] + + @type t :: %__MODULE__{ + host: String.t(), + verbs: [String.t()], + capability: String.t() | nil, + classification: String.t() | nil + } + end + + @type t :: %{default: :allow | :deny, allow: [Entry.t()]} + + @doc """ + Validate the `egress` subsection of a top-level policy map. + + Returns `{:ok, normalised}` (with hosts lowercased) or `{:error, reason}`. + + Accepts a missing/nil section as `{:ok, %{default: :deny, allow: []}}` so + existing inbound-only policies keep working unchanged. + """ + @spec validate(map() | nil) :: {:ok, t()} | {:error, String.t()} + def validate(nil), do: {:ok, %{default: :deny, allow: []}} + + def validate(egress) when is_map(egress) do + with {:ok, default} <- validate_default(Map.get(egress, "default", "deny")), + {:ok, entries} <- validate_allow(Map.get(egress, "allow", [])) do + {:ok, %{default: default, allow: entries}} + end + end + + def validate(_), do: {:error, "egress: must be a map when present"} + + @doc """ + Decide whether an outbound request is permitted. + + Returns `{:allow, entry}` (carrying the matched allowlist entry so the + caller can audit the capability label) or `{:deny, reason}`. + """ + @spec decide(t(), String.t(), String.t()) :: + {:allow, Entry.t()} | {:deny, String.t()} + def decide(policy, host, verb) when is_binary(host) and is_binary(verb) do + host = String.downcase(host) + + case Enum.find(policy.allow, fn e -> e.host == host and verb in e.verbs end) do + %Entry{} = entry -> + {:allow, entry} + + nil -> + case policy.default do + :allow -> {:deny, "no allowlist entry; default=allow not yet supported"} + :deny -> {:deny, "no allowlist entry for #{verb} #{host}; default=deny"} + end + end + end + + # ── internals ─────────────────────────────────────────────────────────── + + defp validate_default("allow"), do: {:ok, :allow} + defp validate_default("deny"), do: {:ok, :deny} + defp validate_default(other), do: {:error, "egress.default: must be \"allow\" or \"deny\", got #{inspect(other)}"} + + defp validate_allow(list) when is_list(list) do + list + |> Enum.with_index() + |> Enum.reduce_while({:ok, []}, fn {entry, idx}, {:ok, acc} -> + case validate_entry(entry, idx) do + {:ok, normalised} -> {:cont, {:ok, [normalised | acc]}} + {:error, _} = err -> {:halt, err} + end + end) + |> case do + {:ok, entries} -> {:ok, Enum.reverse(entries)} + err -> err + end + end + + defp validate_allow(_), do: {:error, "egress.allow: must be a list"} + + defp validate_entry(entry, idx) when is_map(entry) do + with {:ok, host} <- fetch_host(entry, idx), + {:ok, verbs} <- fetch_verbs(entry, idx) do + {:ok, + %Entry{ + host: host, + verbs: verbs, + capability: Map.get(entry, "capability"), + classification: Map.get(entry, "classification") + }} + end + end + + defp validate_entry(_other, idx), do: {:error, "egress.allow[#{idx}]: must be a map"} + + defp fetch_host(entry, idx) do + case Map.get(entry, "host") do + h when is_binary(h) and h != "" -> {:ok, String.downcase(h)} + _ -> {:error, "egress.allow[#{idx}].host: must be a non-empty string"} + end + end + + defp fetch_verbs(entry, idx) do + case Map.get(entry, "verbs") do + verbs when is_list(verbs) and verbs != [] -> + case Enum.find(verbs, &(&1 not in @valid_verbs)) do + nil -> {:ok, verbs} + bad -> {:error, "egress.allow[#{idx}].verbs: invalid HTTP verb #{bad}"} + end + + _ -> + {:error, "egress.allow[#{idx}].verbs: must be a non-empty list"} + end + end +end diff --git a/test/egress_policy_test.exs b/test/egress_policy_test.exs new file mode 100644 index 0000000..73c1779 --- /dev/null +++ b/test/egress_policy_test.exs @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule HttpCapabilityGateway.EgressPolicyTest do + use ExUnit.Case, async: true + + alias HttpCapabilityGateway.EgressPolicy + alias HttpCapabilityGateway.EgressPolicy.Entry + + describe "validate/1" do + test "nil section yields deny-by-default with empty allowlist" do + assert {:ok, %{default: :deny, allow: []}} = EgressPolicy.validate(nil) + end + + test "validates a minimal egress section" do + raw = %{ + "default" => "deny", + "allow" => [ + %{"host" => "api.anthropic.com", "verbs" => ["POST"], "capability" => "llm:complete"} + ] + } + + assert {:ok, policy} = EgressPolicy.validate(raw) + assert policy.default == :deny + assert [%Entry{host: "api.anthropic.com", verbs: ["POST"], capability: "llm:complete"}] = policy.allow + end + + test "rejects unknown default value" do + raw = %{"default" => "whatever", "allow" => []} + assert {:error, msg} = EgressPolicy.validate(raw) + assert msg =~ "egress.default" + end + + test "rejects invalid HTTP verb" do + raw = %{"default" => "deny", "allow" => [%{"host" => "h", "verbs" => ["PROPFIND"]}]} + assert {:error, msg} = EgressPolicy.validate(raw) + assert msg =~ "PROPFIND" + end + + test "rejects empty host" do + raw = %{"default" => "deny", "allow" => [%{"host" => "", "verbs" => ["GET"]}]} + assert {:error, msg} = EgressPolicy.validate(raw) + assert msg =~ "host" + end + + test "host is lowercased on ingest" do + raw = %{"default" => "deny", "allow" => [%{"host" => "API.ANTHROPIC.COM", "verbs" => ["POST"]}]} + assert {:ok, %{allow: [%Entry{host: "api.anthropic.com"}]}} = EgressPolicy.validate(raw) + end + end + + describe "decide/3" do + setup do + {:ok, policy} = + EgressPolicy.validate(%{ + "default" => "deny", + "allow" => [ + %{ + "host" => "api.anthropic.com", + "verbs" => ["POST"], + "capability" => "llm:complete", + "classification" => "redacted-sensor-summary" + } + ] + }) + + %{policy: policy} + end + + test "allows a listed host+verb and returns the matched entry", %{policy: policy} do + assert {:allow, %Entry{capability: "llm:complete"}} = + EgressPolicy.decide(policy, "api.anthropic.com", "POST") + end + + test "denies an unlisted host", %{policy: policy} do + assert {:deny, reason} = EgressPolicy.decide(policy, "evil.example", "POST") + assert reason =~ "default=deny" + end + + test "denies a listed host with the wrong verb", %{policy: policy} do + assert {:deny, _} = EgressPolicy.decide(policy, "api.anthropic.com", "GET") + end + + test "host comparison is case-insensitive (via lowercased ingest)", %{policy: policy} do + assert {:allow, _} = EgressPolicy.decide(policy, "API.anthropic.COM", "POST") + end + end +end