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
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 Managing skills and Network egress policy per group.
Context
The skill and tool subsystems already have a solid, layered base:
- Ingest (ADR-035) fetches
SKILL.behind a GitHub host allow-list, resolves refs to an immutable commit SHA, checksums each body, and arrivesmd enabled = falsefor 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_ 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
- Publisher trust levels, separate from support status.
Skill(Trust Level untrusted<community<verified<first_) classifies aparty Skill's provenance. It is explicitly notSource support_(which is not a safety signal, ADR-035). The level is denormalised onto eachstatus Skillat ingest exactly assupport_is, and an unknown/legacy stored value reads as the lowest level (status from). Injection and the allowed-tools union are gated against a configurable minimum trust level (String Or Untrusted skills., defaultmin Trust Level untrusted= accept every enabled skill):Skill— the single source of truth for both paths — drops any skill below the floor. This composes with, and does not replace, the existingComposer:: effective Skills () enabled = falsedefault:untrustedstill requires an explicit admin enable. - Manifest fingerprint over a full public-key signature. A source may
declare an
expected_: the sha256 its whole discovered skill set must hash to (a canonical, order-independent digest over thefingerprint identifier → body-pairs, degenerating to one entry for a single-file source).checksum Skillrecomputes it after collect and verifies withManifest Verifier hash_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).equals - Prompt-injection scanning at ingest.
Promptruns 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.Injection Scanner - Explicit instruction/data channel separation.
Skillkeeps the trusted guard preamble and additionally fences the untrusted bodies between explicitComposer BEGIN/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.END UNTRUSTED SKILL DATA - Immutable import audit trail.
tx_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:nrllm_ skill_ audit SkillexposesAudit Repository recordand 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 - Per-group egress policies.
Egressdeclares a network-egress scope per tool group (ADR-043) as data, fail-closed: a group with no entry resolves toPolicy Service Tooland may make no outbound request. The one positive scope,Egress Scope:: NONE own_, resolves the instance's own site hosts throughsite Site— the allow-listingFinder probe_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.url
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 carriesprobe_) is grantedurl own_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.site - ✕ 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.