Skip to content

Commit 209b813

Browse files
committed
feat(egress): draft EgressPolicy schema + decide/3 (audit #31, P1)
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)
1 parent 4cda8d7 commit 209b813

3 files changed

Lines changed: 293 additions & 0 deletions

File tree

PROOF-NEEDS.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
4+
# Proof Needs (echo-types audit)
5+
6+
This file records cross-repo proof obligations relevant to this repository,
7+
including an explicit echo-types audit per estate convention
8+
(`feedback_proofs_must_check_and_cross_doc_echo_types.md`).
9+
10+
## Echo-types
11+
12+
**Status: record-as-not-relevant (2026-06-02).**
13+
14+
`hyperpolymath/echo-types` was audited for relevant existing proofs that
15+
this repository should reuse or extend. Findings:
16+
17+
- The trust hierarchy already has a mechanised proof in
18+
`proven/SafeTrust.idr` (referenced from `lib/http_capability_gateway/safe_trust.ex`).
19+
- No L3 (echo) obligation is in scope for the gateway: the gateway does not
20+
participate in any echo protocol; it terminates inbound HTTP and forwards
21+
to a single backend.
22+
- The new `EgressPolicy` module's decision function is a pure first-order
23+
predicate (host + verb membership in an allowlist). It does not interact
24+
with echo types.
25+
26+
**Action:** none. Re-check this entry when egress acquires a
27+
chimichanga-attenuation seam (#84-3 follow-up); attenuation may bring an
28+
L3 obligation into scope at that point.
29+
30+
## SafeTrust monotonicity
31+
32+
Already mechanised in `proven/SafeTrust.idr` (Idris2). The Elixir
33+
implementation in `lib/http_capability_gateway/safe_trust.ex` mirrors the
34+
specification one-to-one. No new proof debt added by this PR.
35+
36+
## EgressPolicy
37+
38+
The egress policy decision function `EgressPolicy.decide/3` is small enough
39+
to mechanise (membership in a list of host+verb pairs). Filing as proof
40+
debt for a future PR; not blocking egress scaffold landing.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
defmodule HttpCapabilityGateway.EgressPolicy do
4+
@moduledoc """
5+
Declarative *outbound* (egress) policy schema for HTTP Capability Gateway.
6+
7+
Companion to the existing inbound `PolicyValidator` / `PolicyCompiler` pair.
8+
Where the inbound pipeline asks "may this client invoke this verb on this
9+
path of our backend?", the egress pipeline asks "may this estate
10+
application emit this verb to that *external* host?".
11+
12+
This is the contract surface needed by `hyperpolymath/neurophone` (#84-3.1)
13+
to bound what sensor-derived data leaves the device on cloud/LLM calls.
14+
15+
## Status
16+
17+
Scaffold only. This module defines the schema, validates it, and answers
18+
`decide/3`. The actual outbound forwarder (`Proxy.egress/2`) is a separate
19+
follow-up — opening it as a thin seam here lets policy authoring start
20+
immediately without committing to a particular HTTP client.
21+
22+
## Schema (DSL v1, egress section)
23+
24+
egress:
25+
# Deny-by-default. An empty allow list denies ALL outbound traffic.
26+
default: deny
27+
# Allow-list of egress destinations. Each entry binds a host + verb
28+
# set + capability label. The capability label is purely informational
29+
# for audit purposes today, but is the seam where chimichanga-style
30+
# attenuation will attach.
31+
allow:
32+
- host: "api.anthropic.com"
33+
verbs: [POST]
34+
capability: "llm:complete"
35+
classification: "redacted-sensor-summary"
36+
37+
- host: "api.openai.com"
38+
verbs: [POST]
39+
capability: "llm:complete"
40+
classification: "redacted-sensor-summary"
41+
42+
Hosts are matched by exact lowercase string. Wildcard / suffix matching is
43+
deliberately NOT supported in v1 — explicit listing is safer for an
44+
egress allowlist and is what neurophone's threat model wants.
45+
"""
46+
47+
require Logger
48+
49+
@valid_verbs ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
50+
51+
defmodule Entry do
52+
@moduledoc "One allow-list entry in an egress policy."
53+
@enforce_keys [:host, :verbs]
54+
defstruct [:host, :verbs, :capability, :classification]
55+
56+
@type t :: %__MODULE__{
57+
host: String.t(),
58+
verbs: [String.t()],
59+
capability: String.t() | nil,
60+
classification: String.t() | nil
61+
}
62+
end
63+
64+
@type t :: %{default: :allow | :deny, allow: [Entry.t()]}
65+
66+
@doc """
67+
Validate the `egress` subsection of a top-level policy map.
68+
69+
Returns `{:ok, normalised}` (with hosts lowercased) or `{:error, reason}`.
70+
71+
Accepts a missing/nil section as `{:ok, %{default: :deny, allow: []}}` so
72+
existing inbound-only policies keep working unchanged.
73+
"""
74+
@spec validate(map() | nil) :: {:ok, t()} | {:error, String.t()}
75+
def validate(nil), do: {:ok, %{default: :deny, allow: []}}
76+
77+
def validate(egress) when is_map(egress) do
78+
with {:ok, default} <- validate_default(Map.get(egress, "default", "deny")),
79+
{:ok, entries} <- validate_allow(Map.get(egress, "allow", [])) do
80+
{:ok, %{default: default, allow: entries}}
81+
end
82+
end
83+
84+
def validate(_), do: {:error, "egress: must be a map when present"}
85+
86+
@doc """
87+
Decide whether an outbound request is permitted.
88+
89+
Returns `{:allow, entry}` (carrying the matched allowlist entry so the
90+
caller can audit the capability label) or `{:deny, reason}`.
91+
"""
92+
@spec decide(t(), String.t(), String.t()) ::
93+
{:allow, Entry.t()} | {:deny, String.t()}
94+
def decide(policy, host, verb) when is_binary(host) and is_binary(verb) do
95+
host = String.downcase(host)
96+
97+
case Enum.find(policy.allow, fn e -> e.host == host and verb in e.verbs end) do
98+
%Entry{} = entry ->
99+
{:allow, entry}
100+
101+
nil ->
102+
case policy.default do
103+
:allow -> {:deny, "no allowlist entry; default=allow not yet supported"}
104+
:deny -> {:deny, "no allowlist entry for #{verb} #{host}; default=deny"}
105+
end
106+
end
107+
end
108+
109+
# ── internals ───────────────────────────────────────────────────────────
110+
111+
defp validate_default("allow"), do: {:ok, :allow}
112+
defp validate_default("deny"), do: {:ok, :deny}
113+
defp validate_default(other), do: {:error, "egress.default: must be \"allow\" or \"deny\", got #{inspect(other)}"}
114+
115+
defp validate_allow(list) when is_list(list) do
116+
list
117+
|> Enum.with_index()
118+
|> Enum.reduce_while({:ok, []}, fn {entry, idx}, {:ok, acc} ->
119+
case validate_entry(entry, idx) do
120+
{:ok, normalised} -> {:cont, {:ok, [normalised | acc]}}
121+
{:error, _} = err -> {:halt, err}
122+
end
123+
end)
124+
|> case do
125+
{:ok, entries} -> {:ok, Enum.reverse(entries)}
126+
err -> err
127+
end
128+
end
129+
130+
defp validate_allow(_), do: {:error, "egress.allow: must be a list"}
131+
132+
defp validate_entry(entry, idx) when is_map(entry) do
133+
with {:ok, host} <- fetch_host(entry, idx),
134+
{:ok, verbs} <- fetch_verbs(entry, idx) do
135+
{:ok,
136+
%Entry{
137+
host: host,
138+
verbs: verbs,
139+
capability: Map.get(entry, "capability"),
140+
classification: Map.get(entry, "classification")
141+
}}
142+
end
143+
end
144+
145+
defp validate_entry(_other, idx), do: {:error, "egress.allow[#{idx}]: must be a map"}
146+
147+
defp fetch_host(entry, idx) do
148+
case Map.get(entry, "host") do
149+
h when is_binary(h) and h != "" -> {:ok, String.downcase(h)}
150+
_ -> {:error, "egress.allow[#{idx}].host: must be a non-empty string"}
151+
end
152+
end
153+
154+
defp fetch_verbs(entry, idx) do
155+
case Map.get(entry, "verbs") do
156+
verbs when is_list(verbs) and verbs != [] ->
157+
case Enum.find(verbs, &(&1 not in @valid_verbs)) do
158+
nil -> {:ok, verbs}
159+
bad -> {:error, "egress.allow[#{idx}].verbs: invalid HTTP verb #{bad}"}
160+
end
161+
162+
_ ->
163+
{:error, "egress.allow[#{idx}].verbs: must be a non-empty list"}
164+
end
165+
end
166+
end

test/egress_policy_test.exs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
defmodule HttpCapabilityGateway.EgressPolicyTest do
4+
use ExUnit.Case, async: true
5+
6+
alias HttpCapabilityGateway.EgressPolicy
7+
alias HttpCapabilityGateway.EgressPolicy.Entry
8+
9+
describe "validate/1" do
10+
test "nil section yields deny-by-default with empty allowlist" do
11+
assert {:ok, %{default: :deny, allow: []}} = EgressPolicy.validate(nil)
12+
end
13+
14+
test "validates a minimal egress section" do
15+
raw = %{
16+
"default" => "deny",
17+
"allow" => [
18+
%{"host" => "api.anthropic.com", "verbs" => ["POST"], "capability" => "llm:complete"}
19+
]
20+
}
21+
22+
assert {:ok, policy} = EgressPolicy.validate(raw)
23+
assert policy.default == :deny
24+
assert [%Entry{host: "api.anthropic.com", verbs: ["POST"], capability: "llm:complete"}] = policy.allow
25+
end
26+
27+
test "rejects unknown default value" do
28+
raw = %{"default" => "whatever", "allow" => []}
29+
assert {:error, msg} = EgressPolicy.validate(raw)
30+
assert msg =~ "egress.default"
31+
end
32+
33+
test "rejects invalid HTTP verb" do
34+
raw = %{"default" => "deny", "allow" => [%{"host" => "h", "verbs" => ["PROPFIND"]}]}
35+
assert {:error, msg} = EgressPolicy.validate(raw)
36+
assert msg =~ "PROPFIND"
37+
end
38+
39+
test "rejects empty host" do
40+
raw = %{"default" => "deny", "allow" => [%{"host" => "", "verbs" => ["GET"]}]}
41+
assert {:error, msg} = EgressPolicy.validate(raw)
42+
assert msg =~ "host"
43+
end
44+
45+
test "host is lowercased on ingest" do
46+
raw = %{"default" => "deny", "allow" => [%{"host" => "API.ANTHROPIC.COM", "verbs" => ["POST"]}]}
47+
assert {:ok, %{allow: [%Entry{host: "api.anthropic.com"}]}} = EgressPolicy.validate(raw)
48+
end
49+
end
50+
51+
describe "decide/3" do
52+
setup do
53+
{:ok, policy} =
54+
EgressPolicy.validate(%{
55+
"default" => "deny",
56+
"allow" => [
57+
%{
58+
"host" => "api.anthropic.com",
59+
"verbs" => ["POST"],
60+
"capability" => "llm:complete",
61+
"classification" => "redacted-sensor-summary"
62+
}
63+
]
64+
})
65+
66+
%{policy: policy}
67+
end
68+
69+
test "allows a listed host+verb and returns the matched entry", %{policy: policy} do
70+
assert {:allow, %Entry{capability: "llm:complete"}} =
71+
EgressPolicy.decide(policy, "api.anthropic.com", "POST")
72+
end
73+
74+
test "denies an unlisted host", %{policy: policy} do
75+
assert {:deny, reason} = EgressPolicy.decide(policy, "evil.example", "POST")
76+
assert reason =~ "default=deny"
77+
end
78+
79+
test "denies a listed host with the wrong verb", %{policy: policy} do
80+
assert {:deny, _} = EgressPolicy.decide(policy, "api.anthropic.com", "GET")
81+
end
82+
83+
test "host comparison is case-insensitive (via lowercased ingest)", %{policy: policy} do
84+
assert {:allow, _} = EgressPolicy.decide(policy, "API.anthropic.COM", "POST")
85+
end
86+
end
87+
end

0 commit comments

Comments
 (0)