---
title: "ADR-093: One tool gate, in the loop — not in the controller"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-093@0.35"
source: "Adr/Adr093ToolGateChokepoint.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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.
