ADR-094: Tool data classes and provider trust zones
- Status
-
Accepted
- Date
-
2026-07-20
- Authors
-
Netresearch DTT GmbH
Context
Tool safety rested on denylists: tables no tool may read, field names that look
like credentials, config files that must not be served. Every audit found
another name nobody had thought of — the extension's own vault-bearing tables,
sys_file_storage.configuration, the encryptionKey, connection strings in
a DSN. Six patches on a single day before the 0.23.0 release widened those lists
again. The lists were not wrong; the shape was. Enumerating the bad is a race
against every future field name, and the field always moves first.
What was missing is the other half of the question. A denylist asks "is this name dangerous". It never asks "dangerous to send where". A run against a locally hosted model and a run against a shared external service were offered the same 38-of-41 default-enabled tools, including environment variables, phpinfo and exception bodies.
Decision
Every tool declares what kind of data it returns. ToolDataClass is a
total order from publicContent up to secretAdjacent. A tool's class
comes from an explicit declaration or, failing that, from its group's default —
each group taking the class of its worst plausible member. An unknown tool or an
undeclared group resolves to secretAdjacent: fail-closed, because an
unclassified tool is precisely the case the classification exists to catch.
Classes are a property of the code, never of configuration. An administrator must not be able to relabel a tool to widen the gate.
Every provider declares where it runs. TrustZone — local,
privateHosted, externalEu, externalGlobal — is stored on the
provider record and implies a ceiling on the data class a run reaching it may
collect. One monotone comparison, not a 6×4 per-installation matrix: a matrix is
configuration nobody gets right, and an operator who disagrees with a ceiling
can move the provider to a different zone.
The zone is an operator declaration, not a technical control. Nothing stops
an administrator labelling an OpenAI provider local; the extension cannot
verify where an endpoint runs. What it buys is that the judgement is made once,
by the person who knows the answer, instead of being implied by whichever tools
happen to be enabled.
The zone that counts is the worst one reachable. FallbackMiddleware hands
the call a different configuration when the primary fails, so a local primary
with an external fallback would be offered secret-adjacent tools and then fail
over, carrying the output with it. TrustZoneResolver therefore takes the
least trusted zone across the configuration and its fallback chain — one level
deep, because fallback is documented as shallow and walking deeper would model a
path that cannot execute.
One gate decides. ToolCallPolicy evaluates all five conditions —
registered, enabled, permitted for the user, within the configuration's groups,
within the zone ceiling — and returns a typed ToolPolicyDecision rather than
a silent absence, so the reason can be shown. Evaluation is a pure AND; the
order only decides which reason is reported, and it runs cheapest and least
revealing first. A tool that is both disabled and above the ceiling reports the
disablement, so a denial never tells a caller who was already blocked that a
trust-zone axis exists.
Enforcement ships in observe mode. tools.dataClassEnforcement defaults to
observe: the decision is computed and logged, the tool is still offered. An
upgrade must not silently strip tools from a working installation. The four
pre-existing gates always enforce; the switch governs only the new axis, so
turning it on can never loosen anything. Anything other than a literal
enforce observes — a typo must not start removing tools from production.
Existing providers are stamped, not reclassified. A new provider defaults to
the strictest zone. Applying that retroactively would be an outage in slow
motion, so StampProviderTrustZoneUpdateWizard writes an explicit zone once,
from the only available signal: an ollama adapter runs locally, everything
else is external until an operator says otherwise. Afterwards the stored column
is the single source of truth — nothing derives a zone from the adapter type at
runtime, because that would make the declaration optional.
Consequences
- A database compare is required, plus running the upgrade wizard.
tx_nrllm_provider.trust_zoneis new; an un-stamped row resolves to the strictest zone, which in enforce mode removes the diagnostics, code and configuration groups from runs against that provider. - The ladder collapses two axes. "How secret" and "where may it go" are not the same question — a single scale cannot express "editorial content, EU only". An operator who needs them apart moves the provider between zones.
- Personal data has no case of its own. The
accountstools return backend users, a GDPR concern rather than a secrecy level. Mapping them tosecretAdjacentis conservative, not semantically precise; a proper PERSONAL_DATA concept needs its own axis, not a rank on this one. - One external fallback drags an otherwise local configuration down to the external ceiling. That is correct — the run really can reach that provider — but it will surprise operators who added a fallback purely for availability.
ToolInterfaceis not changed yet. Classifying by group plus seven explicit declarations covers all 41 builtins without 41 edits; promotinggetDataClass()onto the contract is a later, announced breaking change, once observe-mode evidence exists.isEnabledByDefault()and the "never-toggled group is enabled" default stay as they are. Flipping them would make a fresh install offer zero tools and buy nothing the ceiling does not already buy. The fail-closed default belongs on the new axis — a new provider is external until judged — which is where it is honest.ToolCallPolicyInterfaceis published (public: true), raising the audited public-service count from 33 to 34. This ADR supersedes ADR-084 as the count authority.