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_groups now 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_tca no longer describes the extension's own or nr_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/Retrieval now references Classes/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.