Skip to content

symfonic.services.shadow.redaction

redaction

Field allowlisting, classification-aware redaction, and minimization.

Threat-model §6.2 classifies every element of a recording. TM-29b says the leak happens when scrubbing runs late or partially, so this module is allowlist-first: a field that nobody classified is dropped, not kept. Secrets and security tokens cannot be allowlisted at all — the allowlist refuses to construct — because "we allowlisted the pause token by mistake" must be a configuration error, not an incident.

Credential-shaped keys are dropped even when allowlisted, using the framework's single credential-key pattern, so SEC-CRED-2 holds at capture time regardless of what the allowlist says.

Allowlist rows are leaf rows. A row naming a container never keeps the container wholesale — the walk descends anyway and each leaf inside needs its own row — because a container row would otherwise carry an unclassified, credential-bearing subtree straight into the recording.

AllowedField dataclass

AllowedField(path: str, data_class: DataClass, redaction: Redaction = Redaction.NONE, keep_chars: int = 64)

One dotted path that may be captured, and how it must be transformed.

DataClass

Bases: StrEnum

Threat-model §6.2 classes, in ascending order of "must not capture".

FieldAllowlist

FieldAllowlist(fields: Iterable[AllowedField])

Allowlist-first projection of a payload.

Source code in src/symfonic/services/shadow/redaction.py
def __init__(self, fields: Iterable[AllowedField]) -> None:
    rows = tuple(fields)
    table: dict[str, AllowedField] = {}
    for row in rows:
        if row.path in table:
            raise ValueError(f"duplicate allowlist row for {row.path!r}")
        table[row.path] = row
    self._fields = table

PayloadMinimizer dataclass

PayloadMinimizer(max_chars: int = 512, max_items: int = 32)

Payload minimization: cap string length and collection width.

scrub_credential_keys

scrub_credential_keys(value: Any, path: str = '', dropped: list[str] | None = None) -> Any

Drop credential-shaped keys from any nested structure, at every depth.

The allowlist walk covers mappings, but an allowlisted leaf can still be a list of mappings (a tool result, a message array), and a recorded port answer is not walked by the allowlist at all. SEC-CRED-2 has to hold for those too, so this is the one credential check both paths call.

Source code in src/symfonic/services/shadow/redaction.py
def scrub_credential_keys(
    value: Any, path: str = "", dropped: list[str] | None = None
) -> Any:
    """Drop credential-shaped keys from *any* nested structure, at every depth.

    The allowlist walk covers mappings, but an allowlisted leaf can still be a
    list of mappings (a tool result, a message array), and a recorded port
    answer is not walked by the allowlist at all. SEC-CRED-2 has to hold for
    those too, so this is the one credential check both paths call.
    """
    if isinstance(value, Mapping):
        out: dict[str, Any] = {}
        for raw_key, item in value.items():
            key = str(raw_key)
            child = f"{path}.{key}" if path else key
            if DEFAULT_CREDENTIAL_KEY_PATTERN.search(key):
                if dropped is not None:
                    dropped.append(child)
                continue
            out[key] = scrub_credential_keys(item, child, dropped)
        return out
    if isinstance(value, list | tuple):
        return [
            scrub_credential_keys(item, f"{path}[{index}]", dropped)
            for index, item in enumerate(value)
        ]
    return value