Skip to content

Safe-prefix scans return suffix-only redacted_text and omit the clean prefix #193

Open

Description

@chirag-gamer

When an append-only cumulative scan reuses a verified ALLOW prefix and the suffix scan produces redacted_text, the returned result contains only the suffix's redacted text. The clean skipped prefix is missing.

What happens

merge_suffix_result(suffix, prefix_len) in sdk/src/unplug/core/runtime/cache.py offsets findings globally (correct), but passes through suffix.redacted_text without the prefix body:

def merge_suffix_result(suffix: ScanResult, prefix_len: int) -> ScanResult:
    findings = offset_findings(suffix.findings, prefix_len)
    ...
    redacted: str | None = None
    if suffix.redacted_text is not None and prefix_len > 0:
        redacted = suffix.redacted_text   # <-- suffix only, no prefix body
    ...

The comment says "Caller must merge redacted bodies externally if needed", but neither call site does this:

  • Guard._run_input_with_cache() returns the merged result directly (guard.py:602).
  • StreamScanner._scan_accumulated() returns it directly (streaming.py:96).

The prefix_len argument passed to merge_suffix_result is the scan start offset, not necessarily the full safe prefix length. The omitted text is body[:scan_start].

Reproduction

Direct helper check showing the gap:

from unplug.api.types import ScanResult, Finding
from unplug.api.enums import Action
from unplug.core.runtime.cache import merge_suffix_result

suffix_result = ScanResult(
    safe=True,
    action=Action.ALLOW,
    risk_score=0.0,
    findings=[Finding(
        category="leakage", subcategory="email", stage="regex",
        span_start=0, span_end=15, score=0.8, evidence="test",
    )],
    redacted_text="see [REDACTED] now",
    latency_ms=0,
    stages_run=["leakage"],
)

merged = merge_suffix_result(suffix_result, prefix_len=100)
# findings are at offset 100 (correct), but redacted_text is still
# just "see [REDACTED] now" with no prefix body prepended.
assert merged.findings[0].span_start == 100
assert merged.redacted_text == "see [REDACTED] now"

The full document's redacted_text should be the clean prefix concatenated with the suffix's redacted text. The helper has no prefix-body argument with which to reconstruct it.

Both affected call paths return this incomplete result:

  1. Guard.scan_request(..., document_id=...) via _run_input_with_cache().
  2. StreamScanner._scan_accumulated() via merge_suffix_result().

Expected behavior

If redacted_text represents the safe/redacted version of the scanned document, it should retain the skipped verified-clean text before the redacted suffix. Otherwise the API must explicitly document that it returns a partial result. The current docs do not document it as partial.

Suggested direction

Recompose body[:scan_start] + suffix.redacted_text at call sites that possess the complete original body, or extend the helper with a backward-compatible explicit prefix-text input. Do not silently infer unavailable text inside the helper, and do not alter finding offset behavior.

Environment

Python 3.13.13, Windows 11, dev at cf0b0c7.

Activity

  1. AaliyaKhan25 commented on Sep 27, 2026

    @AaliyaKhan25
    Contributor

    I'd like to take this one. The bug's clear from your repro: merge_suffix_result gets the finding offsets right but drops the safe prefix when rebuilding redacted_text, and neither call site patches that gap.

    I'll go with your suggested fix: add an optional prefix-text param to the helper (backward-compatible, so nothing breaks for existing callers) and have both call sites pass body[:scan_start] in, since they're the ones with the full body in scope anyway. Will also check the case where redacted_text is None but the prefix still needs prepending, so the merged result is always the complete document either way.

    I'll add a test based on your repro as the regression case, and won't touch the finding-offset logic.

  2. chiruu12 commented on Oct 6, 2026

    @chiruu12
    Collaborator

    Holding this one. You have #191 now, and I keep it to one open issue per person until a PR lands. Come back to it then if nobody else has picked it up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions