ADR 061 · Accepted

Skill Trust Levels, Signed Manifests, Injection Scanning


Status:

Accepted

Date:

2026-07-14

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 binds a publisher-declared identity to the exact reviewed bytes (beyond the URL SHA-pin) with no key-management infrastructure. 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. There is no free-form "any host" scope, so a new or mis-declared group can never egress to an arbitrary target.

Consequences

  • ● Publisher identity is now a first-class, fail-closed control: trust gates injection and tool grants, a declared fingerprint binds the reviewed bytes to the publisher, and an unknown trust value 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.

← All ADRs