Skip to content

symfonic.services.effects.exposure

exposure

Opaque-extension exposure accounting, and the claim a revert may make.

Effect fencing binds effects that cross classified framework ports. An opaque in-process extension — a preserved Python tool, a plugin callback, a contributed stage — can open a socket without any port being involved. No lease check saw it, no fence could suppress it, and no cancellation reached it. The only defensible thing a security revert can say about an invocation that executed one is that it is potentially exposed.

So the claim is derived, never set. claim_for starts from "suppressed" and withdraws it on any of three findings, in order of how much they are trusted:

  1. the extension is opaque (T2.3.7 TR-1/TR-2, including the default for anything unregistered) — nothing was mediated, so nothing was fenced;
  2. the extension was declared port-mediated but the sentinel witnessed a direct effect (TR-7) — the declaration was wrong and is demoted here;
  3. the extension was declared port-mediated and carries no attestation at all — the sentinel is a detector rather than a sandbox, so an un-probed direct effect is invisible to it, and the conservative default is the only control against that residual.

Case 3 is the one that matters for mutation coverage: it means a mis-declared extension either gets caught (case 2) or takes the claim down anyway (case 3). There is no configuration that turns it off.

ExposureRecord dataclass

ExposureRecord(invocation_id: str, kind: ExposureKind, subject: str, reason: str, ticket_id: str | None = None)

Something a revert could not suppress, named rather than glossed over.

ExtensionExecution dataclass

ExtensionExecution(invocation_id: str, extension_id: str, attested_clean: bool | None = None)

One extension the invocation actually ran.

attested_clean is tri-state on purpose. True is a sentinel attestation that no direct effect was witnessed, False is a witnessed one, and None — the default — is "nobody watched", which is not the same as "nothing happened".

FencingClaim dataclass

FencingClaim(invocation_id: str, suppressed: bool, withheld_reason: str = '')

What the revert is entitled to say about one invocation's effects.

OpaqueExposureAccountant

OpaqueExposureAccountant(trust: ExtensionTrustRegistry)

Turns executed extensions into exposures and a withheld-or-not claim.

Source code in src/symfonic/services/effects/exposure.py
def __init__(self, trust: ExtensionTrustRegistry) -> None:
    self._trust = trust
    self._executions: dict[str, list[ExtensionExecution]] = {}
    #: Extensions this accountant itself demoted for a witnessed direct
    #: effect. Needed because the demotion is a side effect of the first
    #: assessment: without it, re-reading the same execution would find an
    #: opaque extension and lose the mis-declaration finding.
    self._misdeclared: set[str] = set()

account

account(invocation_ids: Iterable[str]) -> tuple[ExposureRecord, ...]

Every extension exposure for these invocations, demoting as it goes.

Source code in src/symfonic/services/effects/exposure.py
def account(self, invocation_ids: Iterable[str]) -> tuple[ExposureRecord, ...]:
    """Every extension exposure for these invocations, demoting as it goes."""
    records: list[ExposureRecord] = []
    for invocation_id in dict.fromkeys(invocation_ids):
        for execution in self.executions_for(invocation_id):
            finding = self._assess(execution)
            if finding is not None:
                kind, reason = finding
                records.append(
                    ExposureRecord(
                        invocation_id=invocation_id,
                        kind=kind,
                        subject=execution.extension_id,
                        reason=reason,
                    )
                )
    return tuple(records)

claim_for

claim_for(invocation_id: str) -> FencingClaim

Derived from live trust state, so a later demotion still withdraws it.

Source code in src/symfonic/services/effects/exposure.py
def claim_for(self, invocation_id: str) -> FencingClaim:
    """Derived from live trust state, so a later demotion still withdraws it."""
    for execution in self.executions_for(invocation_id):
        finding = self._assess(execution)
        if finding is not None:
            return FencingClaim(
                invocation_id=invocation_id,
                suppressed=False,
                withheld_reason=finding[1],
            )
    return FencingClaim(invocation_id=invocation_id, suppressed=True)