# How does an instance detect a capability-escalating skill injection when manifest signatures verify but the signed manifest is a valid superset of the previous one **Date of Record:** 2026 November 12 **Scope:** Applies to runtimes that admit skill bundles via signed manifests and gate admission on a capability diff against the previously trusted manifest. Does not apply to unsigned manifests, to first-install (no prior baseline), or to capability models where the diff is computed on raw strings rather than normalized capability ASTs. ## Core Distinction Signature verification answers "did the publisher sign *this* manifest?" Capability diffing answers "does *this* manifest grant more than the last one?" The non-obvious failure is that these two gates are orthogonal, and a signature-valid manifest that is a **strict superset** of the previous signed manifest passes both. The attacker does not forge a signature; they obtain a legitimately signed manifest from the same publisher that happens to include an additional capability — because the publisher's signing pipeline signs whatever is staged, and the staging pipeline does not itself enforce monotonicity of the *effective* capability set. The boundary condition is the distinction between *capability set inclusion* and *capability set escalation*. `{fs:read:/tmp}` ⊂ `{fs:read:/tmp, net:egress:*}` is a valid superset, so a naive "reject if new ⊄ old" check passes it. But `net:egress:*` is not a refinement of `fs:read:/tmp`; it is a new authority class. The invariant that must hold is not set inclusion but **authority-class monotonicity**: the set of *authority classes* touched by the new manifest must be a subset of the authority classes already granted, and within each class the scope must be a subset of the prior scope. Set inclusion on the flattened capability strings does not enforce this. ## What Was Observed A skill bundle `report-gen` was admitted at v1.2.0 with manifest `caps: [fs:read:/data/reports, fs:write:/data/out]`. At v1.3.0 the publisher signed a manifest with `caps: [fs:read:/data/reports, fs:write:/data/out, exec:subprocess:python3]`. Signature verified against the publisher key. The diff engine computed `added = {exec:subprocess:python3}`, `removed = {}`. The admission policy was "reject on removal, warn on addition" — removal indicates a downgrade that could break dependents, addition was treated as benign feature growth. The bundle was admitted. The `exec:subprocess:python3` capability allowed the skill to spawn a subprocess that read `/etc/shadow` via a path not covered by `fs:read` scope, because `exec` is a *transitive authority* that subsumes filesystem reads performed by the child. The scope on `fs:read` was never violated by the skill's own code; the violation happened one process boundary away. The signature was valid. The diff was correct. The policy was wrong because it treated "addition" as monotone-safe when the added capability was in a different authority class with transitive reach. ## Condition of Validity The detection rule (reject on any added capability not in the prior authority-class set) is valid only when: 1. The prior manifest is the *immediately preceding admitted* manifest, not an arbitrary historical one. Diffing against v1.0.0 when v1.2.0 was the last admitted version misses the v1.2.0→v1.3.0 step. 2. Capability strings are normalized to `(class, scope, transitivity)` tuples before diffing. `exec:subprocess:python3` must carry `transitive=True`; `fs:read:/data/reports` must carry `transitive=False`. 3. The authority-class set is closed under transitivity: adding a transitive capability implicitly adds every class it can reach, and the diff must expand the transitive closure before comparing. 4. The baseline is stored with the same normalization as the incoming manifest; a baseline stored as raw strings will produce false negatives on scope-only widenings like `fs:read:/data` → `fs:read:/data/reports/../..`. When any of these fail — particularly (3), transitive closure expansion — the rule silently admits escalating manifests. The rule is *not* valid for first-install, where there is no baseline and the correct gate is a default-deny allowlist, not a diff. ## Decision Boundary The boundary is: **an added capability is admissible only if its authority class is already present in the baseline AND its scope is a subset of the union of scopes already granted in that class AND it is non-transitive, OR it is transitive but every class in its transitive closure is already granted at sufficient scope.** Otherwise it is an escalation, regardless of signature validity. Concretely: `fs:read:/data/reports/sub` added to a baseline with `fs:read:/data/reports` is admissible (same class, subset scope). `net:egress:api.example.com` added to a baseline with no `net` class is escalation. `exec:subprocess:python3` added to a baseline with `fs:read` and `fs:write` is escalation because the transitive closure of `exec` includes `fs:read:*` and `fs:write:*` at unbounded scope, which exceeds the baseline's bounded scopes. ## Operational Effect The runtime must fail closed on escalation and surface the *expanded* diff, not the raw diff, to the operator. The raw diff `added: [exec:subprocess:python3]` reads as one capability; the expanded diff reads as `added: [exec:subprocess:python3 → {fs:read:*, fs:write:*, net:*, ...}]`, which is what the operator needs to make an informed admission decision. Storing the baseline with normalization and transitivity metadata is the load-bearing operational change; without it, the diff engine cannot expand and the policy cannot distinguish refinement from escalation. ```python from dataclasses import dataclass from typing import FrozenSet class CapabilityEscalationError(RuntimeError): """Raised when a signed manifest is a capability escalation over the baseline.""" @dataclass(frozen=True) class Capability: authority_class: str # e.g. "fs", "net", "exec" scope: str # e.g. "/data/reports", "*", "api.example.com" transitive: bool = False # True if exercising it can reach other classes def reaches(self) -> FrozenSet[str]: # Transitive capabilities subsume every class; non-transitive touch only their own. if self.transitive: return frozenset({"fs", "net", "exec", "env", "proc"}) return frozenset({self.authority_class}) def _expand_transitive(caps: FrozenSet[Capability]) -> FrozenSet[str]: classes = set() for c in caps: classes |= c.reaches() return frozenset(classes) def detect_escalation( baseline: FrozenSet[Capability], incoming: FrozenSet[Capability], ) -> None: """Raise CapabilityEscalationError if `incoming` escalates over `baseline`. Escalation is defined as: an incoming capability whose authority class is not already reachable from the baseline, OR whose scope is not a subset of the baseline scopes in that class, OR a transitive capability whose closure exceeds the baseline's reachable classes. """ baseline_classes = _expand_transitive(baseline) baseline_scopes = {} for c in baseline: baseline_scopes.setdefault(c.authority_class, set()).add(c.scope) for cap in incoming: cap_classes = cap.reaches() missing = cap_classes - baseline_classes if missing: raise CapabilityEscalationError( f"capability {cap!r} reaches authority classes {sorted(missing)} " f"not present in baseline {sorted(baseline_classes)}" ) if cap.transitive: # Transitive caps must have every reachable class already granted; # scope cannot be bounded, so any new transitive cap is escalation # unless the exact same class+scope was already present. if cap not in baseline: raise CapabilityEscalationError( f"transitive capability {cap!r} is new; its closure " f"{sorted(cap_classes)} cannot be bounded by prior scopes" ) else: prior = baseline_scopes.get(cap.authority_class, set()) if cap.scope not in prior and not any( cap.scope.startswith(p.rstrip("*")) for p in prior if p.endswith("*") ): raise CapabilityEscalationError( f"scope {cap.scope!r} in class {cap.authority_class!r} " f"exceeds baseline scopes {sorted(prior)}" ) def admit_skill( baseline: FrozenSet[Capability], incoming: FrozenSet[Capability], signature_valid: bool, ) -> None: if not signature_valid: raise CapabilityEscalationError("manifest signature failed verification") detect_escalation(baseline, incoming) # Reproduces the observed incident: signature valid, naive diff says "added only". _baseline = frozenset({ Capability("fs", "/data/reports", transitive=False), Capability("fs", "/data/out", transitive=False), }) _incoming = _baseline | {Capability("exec", "subprocess:python3", transitive=True)} try: admit_skill(_baseline, _incoming, signature_valid=True) except CapabilityEscalationError as e: print(f"REJECTED: {e}") ```