Skip to content

feat: regulated refuses an unnamed gateway; and the fail-open narrowing - #114

Merged
HackTuah merged 2 commits into
mainfrom
fix/findings-oct
Oct 8, 2026
Merged

HackTuah merged 2 commits into
mainfrom
fix/findings-oct

Conversation

@HackTuah

@HackTuah HackTuah commented Oct 7, 2026

Copy link
Copy Markdown
Member

Two independent changes, one per commit. Both come out of the DoD-first direction and the research behind it.

1. TRINITY_REGULATED_GATEWAYS — an unnamed channel does not start

The profile refused an unlisted model endpoint by name and said nothing at all about gateways. So a regulated node could boot with a consumer chat cloud attached while being refused an unlisted model, with the same conversation text crossing the boundary either way. That asymmetry was the defect.

Under :regulated, every adapter in config :trinity, :gateways's :adapters must now be named in TRINITY_REGULATED_GATEWAYS or the boot is refused before any child starts. :default is untouched.

A node with no gateway configured needs no allow-list. Requiring the variable where nothing reaches out would make every operator set one they do not use, which is how a control becomes a formality.

Why this is an allow-list and not a ban

It nearly went the other way. An authorizable channel exists:

Mattermost is deployed at IL4, IL5 and IL6 including JWICS, holds a Certificate to Field under Platform One's continuous ATO, authenticates with CAC through SAML, and offers the interactive dialogs an approval round-trip needs. Platform One's own ChatOps is Mattermost.

Banning the category would forbid what the deployment target itself runs. What this refuses is an unnamed channel, which is how a consumer cloud gets in by configuration. No such adapter ships in this tree.

The allow-list and the channel cap are independent and both apply. lib/trinity/gateways/cap.ex admits :read, :network and :write from a paired channel and refuses :exec and :destructive whatever the profile. The list decides whether a channel exists; the cap decides what it may do.

Proven at a boot

Node case 7 runs two children with the same configured adapter and only the allow-list differing:

without TRINITY_REGULATED_GATEWAYS -> status 3, reason names regulated_gateways_unset
                                      and TRINITY_REGULATED_GATEWAYS
with    TRINITY_REGULATED_GATEWAYS -> BOOT_OK

Without that second half a refusal would prove only that the check fires, not that it can be satisfied. Six pure tests cover the decision function, including that every adapter must be listed rather than one of them, and that Foo.Bar and Elixir.Foo.Bar are the same entry.

Documented for a reviewer in docs/regulated/authorization-boundary.md, in the "what crosses the boundary" table and a section that states plainly what the control does and does not stop.

2. SCR-815 — a keys directory that cannot be made is an error, not a raise

Trinity.Receipts.Supervisor fails open for every profile that is not :regulated: it logs, sets the alarm, and lets the tree start with every effect denied. It does that on an error tuple.

Both ways of resolving the keys directory create it with the raising File functions — KeyCustody.keys_dir/0 calls File.mkdir_p!/1 on a configured path, Trinity.Paths.keys_dir/0 calls File.mkdir_p!/1 and File.chmod!/2 on the default one. A raise is not an error tuple, so it never reached the code that decides to fail open:

BOOT_REFUSED {:trinity, {{:shutdown, {:failed_to_start_child,
  Trinity.Receipts.Supervisor, {%File.Error{reason: :enotdir, ...

A :default node refused to start, which is the opposite of the documented promise. The shape is ordinary: a regular file where the keys directory belongs, left by a sync client, a restore, or a packaging mistake.

boot!/1 now resolves through resolve_keys_dir/1, which rescues File.Error and answers {:error, {:keys_dir, reason}}. The rescue sits at the boundary where the contract changes rather than at either call site, and neither keys_dir/0 nor Trinity.Paths.keys_dir/0 changes contract for its other callers.

receipts/supervisor.ex is untouched. It already handled the error tuple correctly; the defect was that it never received one.

Red first, both tests failing on the raise:

** (File.Error) could not make directory (with -p) "/tmp/keys-dir-as-file-9989": not a directory

Evidence

mix gate
prod_check: PASS
3838 mods/funs, found no issues.
No vulnerabilities found.
trinity.names: OK over 676 tracked files
Result: 960 passed (20 properties, 940 tests), 18 excluded
trinity.coverage: 042 80.31% vs 005 80.31%: OK
GATE_EXIT=0

960 against 951 on main: six profile tests, two key-custody tests, one boot case.

No new slice id, no ROADMAP row. Not merged.

SCR-815. Trinity.Receipts.Supervisor fails open for every profile that is not
:regulated: it logs, sets the alarm, and lets the tree start with every effect
denied until a signer returns. It does that on an error tuple.

Both ways of resolving the keys directory create it with the raising File
functions. KeyCustody.keys_dir/0 calls File.mkdir_p!/1 on a configured path, and
Trinity.Paths.keys_dir/0 calls File.mkdir_p!/1 and File.chmod!/2 on the default
one. A raise is not an error tuple, so it never reached the code that decides to
fail open, and the boot went down under every profile including :default, where
the documented promise is the opposite. Measured from a spawned :default boot:

  BOOT_REFUSED {:trinity, {{:shutdown, {:failed_to_start_child,
    Trinity.Receipts.Supervisor, {%File.Error{reason: :enotdir, ...

The shape is ordinary rather than exotic: a regular file where the keys directory
belongs, left by a sync client, a restore from backup, or a packaging mistake.

boot!/1 now resolves the directory through resolve_keys_dir/1, which rescues
File.Error and answers {:error, {:keys_dir, reason}}. The rescue sits at the
boundary where the contract changes rather than at either call site, and neither
keys_dir/0 nor Trinity.Paths.keys_dir/0 changes contract for its other callers.

receipts/supervisor.ex is untouched. It already handled the error tuple
correctly; the defect was that it never received one.

Red first, both tests failing on the raise before the fix:
  ** (File.Error) could not make directory (with -p) "/tmp/keys-dir-as-file-9989": not a directory

Signed-off-by: Ayla Croft <aylacroft@proton.me>
A gateway carries conversation text to somewhere that is not this host. That is
the same egress a model endpoint is, with the same contents. The profile already
refused an unlisted model endpoint by name and said nothing at all about
gateways, so a regulated node could boot with a consumer chat cloud attached
while being refused an unlisted model. That asymmetry is the whole defect.

TRINITY_REGULATED_GATEWAYS is now the allow-list, mirroring
TRINITY_REGULATED_LLM_ENDPOINTS: under :regulated every adapter in
config :trinity, :gateways's :adapters must be named, or the boot is refused
before any child starts. The refusal names the adapter, or names the variable
when nothing is set. :default is untouched.

A node with no gateway configured needs no allow-list. Requiring the variable
where nothing reaches out would make every operator set one they do not use,
which is how a control becomes a formality.

This is deliberately not a ban on gateways, and the research behind that is worth
recording because it was close to going the other way. An authorizable channel
exists: Mattermost runs at IL4, IL5 and IL6 including JWICS, holds a Certificate
to Field under Platform One's continuous ATO, authenticates with CAC through
SAML, and carries the interactive dialogs an approval round-trip needs. Platform
One's own ChatOps is Mattermost. Refusing the category would forbid what the
deployment target itself runs; what this refuses is an unnamed channel, which is
how a consumer cloud gets in by configuration. No such adapter ships here.

The allow-list and the channel cap are independent and both still apply:
gateways/cap.ex admits :read, :network and :write from a paired channel and
refuses :exec and :destructive whatever the profile. The list decides whether a
channel exists; the cap decides what it may do.

Proven at a boot, not only as a decision function. Node case 7 runs two children
with the same configured adapter and only the allow-list differing: without it
the node refuses naming regulated_gateways_unset and the variable, with it the
node starts. Without that second half a refusal would prove only that the check
fires, not that it can be satisfied.

mix gate: 960 passed, exit 0.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit 5c1a9ba into main Oct 8, 2026
8 checks passed
@HackTuah
HackTuah deleted the fix/findings-oct branch October 8, 2026 02:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant