ADR-093: One tool gate, in the loop — not in the controller
- Status
-
Accepted
- Date
-
2026-07-20
- Authors
-
Netresearch DTT GmbH
Context
The tool subsystem enforces its policy in several places, and an audit found that three of those places disagreed with what the documentation claimed.
The per-configuration gate was in the wrong layer. A configuration's
allowed_tool_groups and its skills' declared allow-list were applied by
ToolPlaygroundController, not by the loop. That was defensible while the
playground was the only entry point. It stopped being defensible when
ToolLoopServiceInterface was published in 0.23.0 so downstream extensions
could drive the loop: every such consumer bypassed the configuration's own
restriction entirely and received the full globally-enabled set.
Two schema tools disagreed on what may be read. get_full_tca routed its
decision through TableReadAccessService, whose sensitive-table denylist
holds for administrators too. get_tca checked tables_select directly —
and BackendUserAuthentication::check() returns true for every table for an
admin, so get_tca described tx_nrllm_provider and the nr_vault
tables to any admin-run loop, vault key references included.
A documented egress invariant was false. EgressPolicyService stated that
a group without an entry in its map "cannot egress anywhere" and listed rag
among the groups that "read local state only". Meanwhile the RAG tools reach
SolrSearchBackend, which assembles a scheme://host:port URL from the site
configuration and hands it to an HTTP client without ever consulting the policy.
Decision
The loop is the chokepoint. ToolLoopService::resolveOfferedNames() now
applies the per-configuration gate itself, alongside the global enablement
intersection and the fail-closed admin filter it already applied. The caller's
tool list is a request; the configuration is the grant. resume() runs
the same resolution, so a run suspended before a configuration was tightened is
re-checked at approval time.
ToolLoopServiceInterface, runLoop() and resume() are unchanged —
both already receive the LlmConfiguration. The playground keeps passing the
admin's checkbox selection and simply stops applying the gate a second time, so
its observable behaviour is identical.
One table policy. get_tca routes both its list and its describe path
through TableReadAccessService::canReadTable(). A denied table returns the
same neutral Unknown TCA table. string as an unknown one, so the tool never
confirms a table's existence.
The egress map tells the truth. A new scope,
ToolEgressScope::CONFIGURED_ENDPOINT, expresses an operator-declared service
host that is not a site base — OWN_SITE cannot describe a Solr host, so
without it the map could only be satisfied by misdeclaring the group. rag
maps to it, and SolrSearchBackend validates its assembled URL through
EgressPolicyService::resolveConfiguredEndpoint(): http(s) only, no userinfo,
exact host:port match against the configured host. A denial returns null, the
method's established "not configured" path, which the retrieval service treats
as an unavailable backend and skips.
Consequences
- The published ``ToolLoopServiceInterface`` contract narrows. A consumer
that previously received tools outside its configuration's
allowed_tool_groupsnow receives fewer. No shipped behaviour changes — the only production consumer already applied the intersection — but this is a deliberate tightening and belongs in the release notes. get_tcano longer describes the extension's own ornr_vault's tables, to anyone, including administrators. That is the point.- The RAG egress gate is an audit and consistency gate, not a new confidentiality boundary. The Solr host was always operator-supplied from the site configuration and never model-supplied; what changes is that the invariant this class documents is now checkable in code instead of merely asserted. Overselling it would be dishonest.
- Both new collaborators are optional constructor arguments so the existing lean test wiring keeps working. In production the container injects them; a unit test that omits them exercises the pre-existing gates only.
Classes/Service/Retrievalnow referencesClasses/Service/Tool. That crosses a horizontal seam ADR-090 names but does not yet enforce. The alternative — duplicating URL validation inside the retrieval module — would be a second policy, which is the very failure this ADR is correcting.- Still open, and deliberately not addressed here: tools carry no data classification and providers no trust zone, so nothing yet prevents a diagnostics tool's output from egressing to an external provider. That is the next step and needs its own ADR.