Skip to content

symfonic.agent.hygiene

hygiene

Memory-hygiene utilities.

v6.2 T01: promotes the v6.1.9 credential-key scrubber to a public module and exposes the pattern list as a tunable FrameworkConfig.credential_patterns knob.

Design constraints (strictly additive against v6.1.9):

  • The built-in default pattern set is byte-for-byte identical to the inline regex that shipped in v6.1.9 (engine.py L110-113). Callers that do not touch FrameworkConfig.credential_patterns keep the exact v6.1.9 behaviour.
  • credential_patterns=None (the default) uses the built-in list.
  • credential_patterns=[] (empty list) disables scrubbing. This is explicit opt-out, not a silent no-op — customers must knowingly ship a scrubber-less deployment.
  • credential_patterns=[...] with one or more strings REPLACES the built-in list. This is the simpler contract vs. extend-mode: if we ever ship an extend knob it can be added additively later without changing this semantic.

The regex engine compiles once per compiled-pattern object. Callers that scrub repeatedly should cache the compiled object via :func:compile_credential_pattern rather than re-building it per call.

compile_credential_pattern

compile_credential_pattern(
    patterns: list[str] | tuple[str, ...] | None,
) -> re.Pattern[str] | None

Build a single alternation regex from a list of pattern fragments.

  • None -> return :data:DEFAULT_CREDENTIAL_KEY_PATTERN (v6.1.9).
  • Empty list/tuple -> return None (explicit opt-out of scrubbing).
  • Non-empty list -> compile each fragment as a sub-alternation and OR them together into one case-insensitive regex.

Invalid regex fragments raise :class:re.error at compile time, not silently at scrub time — a misconfigured pattern list should fail fast during config construction, not leak secrets at runtime.

Source code in src/symfonic/agent/hygiene.py
def compile_credential_pattern(
    patterns: list[str] | tuple[str, ...] | None,
) -> re.Pattern[str] | None:
    """Build a single alternation regex from a list of pattern fragments.

    - ``None`` -> return :data:`DEFAULT_CREDENTIAL_KEY_PATTERN` (v6.1.9).
    - Empty list/tuple -> return ``None`` (explicit opt-out of scrubbing).
    - Non-empty list -> compile each fragment as a sub-alternation and
      OR them together into one case-insensitive regex.

    Invalid regex fragments raise :class:`re.error` at compile time, not
    silently at scrub time — a misconfigured pattern list should fail fast
    during config construction, not leak secrets at runtime.
    """
    if patterns is None:
        return DEFAULT_CREDENTIAL_KEY_PATTERN
    if len(patterns) == 0:
        return None
    # Wrap each user fragment in its own group so the outer alternation
    # preserves atomicity even if the fragment itself contains ``|``.
    joined = "|".join(f"(?:{p})" for p in patterns)
    return re.compile(r"(?i)(" + joined + r")")

scrub_credential_keys

scrub_credential_keys(
    properties: Any,
    pattern: Pattern[str] | None | object = _UNSET,
) -> Any

Drop dict keys whose names match the credential-key pattern.

Public v6.2 API. Byte-for-byte compatible with the private v6.1.9 _scrub_credential_keys when called with the default pattern.

Parameters:

Name Type Description Default
properties Any

Candidate mapping. Non-dict / empty inputs are returned unchanged (idempotent).

required
pattern Pattern[str] | None | object

Compiled credential-key pattern. When omitted the v6.1.9 built-in :data:DEFAULT_CREDENTIAL_KEY_PATTERN is used. Explicit None disables scrubbing (opt-out path, e.g. a customer running credential_patterns=[]).

_UNSET

Returns:

Type Description
Any

A new dict with the offending keys removed, or the original

Any

input when no scrub applies.

Scope note (v6.1.9 invariant, preserved): Shallow scrub only — nested dicts inside a value are NOT walked, because graph properties are persisted flat. Value scanning (regex over free-form strings) is a separate design track that carries a false-positive tax and is intentionally out of scope.

Emits a DEBUG log record listing every key removed. Values are never logged.

Source code in src/symfonic/agent/hygiene.py
def scrub_credential_keys(
    properties: Any,
    pattern: re.Pattern[str] | None | object = _UNSET,
) -> Any:
    """Drop dict keys whose names match the credential-key ``pattern``.

    Public v6.2 API. Byte-for-byte compatible with the private v6.1.9
    ``_scrub_credential_keys`` when called with the default ``pattern``.

    Args:
        properties: Candidate mapping. Non-dict / empty inputs are
            returned unchanged (idempotent).
        pattern: Compiled credential-key pattern. When omitted the
            v6.1.9 built-in :data:`DEFAULT_CREDENTIAL_KEY_PATTERN` is
            used. Explicit ``None`` disables scrubbing (opt-out path,
            e.g. a customer running ``credential_patterns=[]``).

    Returns:
        A new dict with the offending keys removed, or the original
        input when no scrub applies.

    Scope note (v6.1.9 invariant, preserved):
        Shallow scrub only — nested dicts inside a value are NOT walked,
        because graph properties are persisted flat. Value scanning
        (regex over free-form strings) is a separate design track that
        carries a false-positive tax and is intentionally out of scope.

    Emits a DEBUG log record listing every key removed. Values are never
    logged.
    """
    if not isinstance(properties, dict) or not properties:
        return properties
    if pattern is _UNSET:
        active_pattern: re.Pattern[str] | None = DEFAULT_CREDENTIAL_KEY_PATTERN
    else:
        active_pattern = pattern  # type: ignore[assignment]
    # Explicit opt-out mode.
    if active_pattern is None:
        return properties
    clean: dict[str, Any] = {}
    scrubbed: list[str] = []
    for k, v in properties.items():
        if isinstance(k, str) and active_pattern.search(k):
            scrubbed.append(k)
            continue
        clean[k] = v
    if scrubbed:
        logger.debug(
            "memory-hygiene: scrubbed %d credential-shaped key(s): %s",
            len(scrubbed),
            scrubbed,
        )
    return clean