Skip to content

[BUG]: TRANSFORM mode silently discards suppressed violation from PluginResult.violation #172

Description

@jonpspri

Summary

PluginMode.TRANSFORM (the backing mode for operator-set permissive) logs violations at WARNING level when a plugin returns continue_processing=False, but the suppressed violation is never surfaced in PluginResult.violation. The field is always None on the result returned by invoke_hook() when no SEQUENTIAL or CONCURRENT plugin has halted the pipeline.

Reproduction path

In framework/manager.py, when the pipeline completes without a halt:

# line ~541-553 (v0.1.3)
return (
    PluginResult(
        continue_processing=True,
        modified_payload=current_payload,
        ...
        violation=None,   # ← always None, even if TRANSFORM plugins suppressed a violation
        ...
    ),
    ...
)

Meanwhile _run_serial_phase with allow_blocking=False (TRANSFORM) logs the violation and discards the halt:

# ~line 873-880
logger.warning(
    "%s plugin %s returned continue_processing=False on hook %s%s; "
    "pipeline continues (blocking suppressed)",
    ...
)
# halt result is discarded — violation never propagated upward

A caller doing result.violation is not None after invoke_hook() in permissive mode will always see None, even when a plugin fired a rate-limit, PII, or other policy violation.

Why it matters

  • Observability gap: permissive mode is the natural staging strategy for "log but don't enforce yet". With violations dropped from PluginResult, downstream metrics, audit trails, and structured logs lose them. The only remaining signal is a WARNING log line — fine for humans, useless for programmatic consumers.
  • Test brittleness: assertions like assert result.violation is not None under permissive mode silently pass incorrectly.
  • Partial workaround exists: the PluginResult.executions list does carry per-plugin records with effective_allow=False for suppressed plugins, so callers willing to walk that list can recover the data — but this is non-obvious and undocumented as the intended access pattern.

Operator mapping (in ContextForge gateway)

Operator mode PluginMode Violation surfaced?
enforce SEQUENTIAL ✅ yes
permissive TRANSFORM ❌ no (this bug)
disabled DISABLED n/a

Proposed fix (preferred)

In PluginManager.invoke_hook(), accumulate any suppressed violations from the TRANSFORM phase and populate PluginResult.violation (or a new suppressed_violation field) on the final result. The key invariant: suppressing the raise/halt should be independent of suppressing observability.

Option A — reuse violation field, change semantics to "last violation encountered (may or may not have halted)":

return (
    PluginResult(
        continue_processing=True,
        violation=suppressed_violation,  # populated from TRANSFORM phase
        ...
    ),
    ...
)

Option B — add suppressed_violation: Optional[PluginViolation] = None to preserve existing semantics of violation ("raised → halt") while exposing non-halting ones separately.

Option C — document PluginResult.executions as the intended access path for permissive-mode observability and provide a helper to extract suppressed violations from it.

Version

cpex 0.1.3

Cross-reference

Filed in the ContextForge gateway repo as IBM/mcp-context-forge#4710.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions