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 ADR-202; items 1 and 4: the trust level also decides whether an approved version is fenced or an instruction — decided by 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 ADR-202 (item 6: a filtered external scope now exists); 2026-10-08 by ADR-214 (items 1 and 4)

Authors

Netresearch DTT GmbH

Context 

The skill and tool subsystems already have a solid, layered base:

  • Ingest (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 (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 (ADR-038, 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.

Decision 

  1. Publisher trust levels, separate from support status. SkillTrustLevel (untrusted < community < verified < first_party) classifies a SkillSource's provenance. It is explicitly not support_status (which is not a safety signal, ADR-035). The level is denormalised onto each 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): 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). 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 consequences).
  3. Prompt-injection scanning at ingest. 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. 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 (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: 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. EgressPolicyService declares a network-egress scope per tool group (ADR-043) as data, fail-closed: a group with no entry resolves to 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.

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 ADR-035 and ADR-036 for the ingest / injection base, ADR-038 and ADR-043 for the tool runtime and groups, and the administration guide for operation.