.. SPDX-License-Identifier: CC-BY-4.0 SPDX-FileCopyrightText: Netresearch DTT GmbH .. include:: /Includes.rst.txt .. _adr-061: ================================================================== ADR-061: Skill trust levels, manifest fingerprints, injection scanning ============================= and per-group egress policies ============================= :Status: Accepted (item 6: a filtered external egress scope now exists — see :ref:`ADR-202 `; items 1 and 4: the trust level also decides whether an approved version is fenced or an instruction — decided by :ref:`ADR-214 `, implemented — and a backend-authored skill is admitted by its source's level — decided there, implemented) :Date: 2026-07-14 :Amended: 2026-09-23 by :ref:`ADR-202 ` (item 6: a filtered external scope now exists); 2026-10-08 by :ref:`ADR-214 ` (items 1 and 4) :Authors: Netresearch DTT GmbH .. _adr-061-audit-clarification: .. note:: Manifest verification is a declared SHA-256 fingerprint check, not a public-key signature. Its assurance depends on how the administrator obtained the expected digest. Egress checks constrain cooperating tool paths, not arbitrary PHP. See :ref:`administration-skills` and :ref:`administration-tools-egress`. .. _adr-061-context: Context ======= The skill and tool subsystems already have a solid, layered base: - **Ingest** (:ref:`ADR-035 `) fetches ``SKILL.md`` behind a GitHub host allow-list, resolves refs to an immutable commit SHA, checksums each body, and arrives ``enabled = false`` for repo/marketplace skills. - **Injection** (:ref:`ADR-036 `) re-verifies the checksum fail-closed at compose time, never uses the system role, and fences the block behind a guard preamble. - **Tools** (:ref:`ADR-038 `, :ref:`ADR-043 `) enforce a fail-closed allow-list, per-tool admin gating against the acting backend user, and a group enable-cascade. Four gaps remain. The SHA-pin binds *bytes to a URL* but not *bytes to a publisher identity*. The ingest inspects structure but never looks at the prose for prompt-injection payloads. The instruction/data boundary rests on a guard sentence and message role, without an explicit data delimiter. And there is no immutable record of *who* imported or enabled *which* skill *when* — the sync overwrites its own state. Separately, network egress is decided per tool (``probe_url`` hard-codes its own site allow-list) rather than declared per tool group, so a new or third-party tool's egress is governed by nothing. .. _adr-061-decision: Decision ======== 1. **Publisher trust levels, separate from support status.** :php:`SkillTrustLevel` (``untrusted`` < ``community`` < ``verified`` < ``first_party``) classifies a :php:`SkillSource`'s *provenance*. It is explicitly **not** ``support_status`` (which is not a safety signal, :ref:`ADR-035 `). The level is denormalised onto each :php:`Skill` at ingest exactly as ``support_status`` is, and an unknown/legacy stored value reads as the lowest level (``fromStringOrUntrusted``). Injection and the allowed-tools union are gated against a configurable **minimum** trust level (``skills.minTrustLevel``, default ``untrusted`` = accept every enabled skill): :php:`SkillComposer::effectiveSkills()` — the single source of truth for both paths — drops any skill below the floor. This composes with, and does not replace, the existing ``enabled = false`` default: ``untrusted`` still requires an explicit admin enable. 2. **Manifest fingerprint over a full public-key signature.** A source may declare an ``expected_fingerprint``: the sha256 its whole discovered skill set must hash to (a canonical, order-independent digest over the ``identifier → body-checksum`` pairs, degenerating to one entry for a single-file source). :php:`SkillManifestVerifier` recomputes it after collect and verifies with ``hash_equals`` **before** any skill is materialised; a mismatch fails closed — no upsert, no orphaning — leaving the last known-good skills untouched, and is audited. We chose a declared expected digest over a detached public-key signature deliberately. It checks identifiers and body checksums against an expected digest obtained by the administrator. It does not authenticate the publisher independently of that digest's delivery channel, and does not cover frontmatter, trust or approval metadata. Full detached signatures over a signed manifest listing per-skill digests are a documented follow-up (see :ref:`consequences `). 3. **Prompt-injection scanning at ingest.** :php:`PromptInjectionScanner` runs a data-driven, auditable pattern set over each body and records the findings on the skill. Tiering is conservative to avoid over-blocking legitimate prose: only **high-confidence** jailbreak markers (instruction override, role reset, DAN/developer-mode personas, chat-template control tokens) **force-disable** the skill fail-closed at import — even a single-file source that would otherwise default enabled. Medium/low findings (secret-exposure verbs, guardrail-bypass wording, covert behaviour, long encoded blobs) only *flag* the record for review, since the repo/marketplace skills they most often appear on already arrive disabled. 4. **Explicit instruction/data channel separation.** :php:`SkillComposer` keeps the trusted guard preamble and additionally fences the untrusted bodies between explicit ``BEGIN/END UNTRUSTED SKILL DATA`` markers that label them as reference data the model must not execute as instructions. Message role remains defence-in-depth, **not** a trust boundary (:ref:`ADR-036 `); the markers give the model an unambiguous boundary in-band. 5. **Immutable import audit trail.** ``tx_nrllm_skill_audit`` records who / when / which source / which SHA / which checksum / which trust level / which scan result for every ingest outcome, enable, disable and fail-closed rejection. It is **append-only by construction**: :php:`SkillAuditRepository` exposes ``record()`` and read helpers and offers no update or delete path, and the table carries no soft-delete column. Any purge is a separate, documented retention operation — never the regular write path. The trail is additive to the existing sync; no backend UI (UI-less log, like ``tx_nrllm_tool_state``). 6. **Per-group egress policies.** :php:`EgressPolicyService` declares a network-egress scope per tool group (:ref:`ADR-043 `) as data, fail-closed: a group with no entry resolves to :php:`ToolEgressScope::NONE` and may make no outbound request. The one positive scope, ``own_site``, resolves the instance's own site hosts through ``SiteFinder`` — the allow-listing ``probe_url`` previously hard-coded, now lifted to the group boundary and consulted by the tool through the shared gate. The gate has no unfiltered any-host scope. A custom PHP tool must call it; this does not prevent a separate HTTP client from using the network. .. _adr-061-consequences: Consequences ============ - ● Operator-declared trust gates injection and tool grants. A fingerprint checks expected identifiers/body checksums; it is not a public-key signature or independent publisher authentication. Unknown trust reads as the lowest. - ● Prompt-injection payloads are caught at the door: high-confidence jailbreaks never reach an enabled state, and every body's scan result is retained for review and audit. - ● The audit trail makes the provenance of any skill that can reach a prompt reconstructable after the fact, and cannot be rewritten through the app. - ● Egress is declared per group and fail-closed; the network reach of the tool surface is now one auditable table, not scattered per-tool constants. - ◐ The audit table grows monotonically (one row per ingest/enable/disable). At the expected admin-driven cadence this is negligible; a retention command is a documented follow-up if ever needed. - ◐ Egress scope is keyed by the existing coarse group taxonomy, so ``system`` (which carries ``probe_url``) is granted ``own_site`` even though it also holds non-egressing diagnostics. Those tools never call the gate, so the grant does not loosen them; a dedicated network group is a possible follow-up. - ✕ **Deferred:** full detached public-key signatures over a signed per-skill manifest (the fingerprint is a declared expected digest, not a PKI signature); an admin-review queue UI for medium/low injection findings; and a dedicated egress group split. Each is additive to the fail-closed base delivered here. See :ref:`ADR-035 ` and :ref:`ADR-036 ` for the ingest / injection base, :ref:`ADR-038 ` and :ref:`ADR-043 ` for the tool runtime and groups, and :ref:`the administration guide ` for operation.