Skip to content

scan_output() taints clean sessions and forces side-effect tool review #158

Description

@chirag-gamer

I was following the agent checklist in sdk/README.md with a default Guard: scan every model response with guard.scan_output(text), then check side-effect tools before running them. On current dev (cf0b0c7), scanning an ordinary benign model response marks the shared session as tainted, so the very next side-effect call that was allowed before the scan now comes back as REVIEW. That makes the taint review fire on sessions where nothing untrusted was ever ingested.

Observed behavior

scan_output() scans model output with Source.TOOL_OUTPUT, and afterwards guard._maybe_mark_session_tainted_from_scan() adds scan:tool_output to the shared context's taint triggers. This happens even when the output is completely benign and no retrieved or external content entered the session.

The README's own checklist sets up the conflict:

  • Step 5 says to scan agent output: "Scan agent output. guard.scan_output(text)."
  • The same page explains that "A tainted session plus a side-effect tool returns REVIEW."

So a host doing exactly what the docs say ends up with every side-effect tool held for review after the first model response, benign or not. It also dilutes the signal of the review itself: if the session is always tainted, REVIEW stops meaning "untrusted data reached the session".

Minimal reproduction

Run on dev at cf0b0c7, Python 3.13, Windows 11, installed via uv sync --all-extras --dev:

from unplug import Guard
from unplug.api.enums import Action, Source

# Control: a clean session allows a representative side-effect tool.
guard = Guard()
assert guard.context.is_session_tainted is False
baseline = guard.check_tool_call("shell", {"command": "echo hello"})
assert baseline.action == Action.ALLOW

# Documented host step: scan an ordinary model response.
result = guard.scan_output("The answer is 42.")
assert result.safe is True
assert result.action == Action.ALLOW

# Benign output scan mutated shared taint state.
assert guard.context.is_session_tainted is True
assert any("scan:tool_output" in t for t in guard.context.taint_triggers)

# The same call is now held for review solely because of that output scan.
review = guard.check_tool_call("shell", {"command": "echo hello"})
assert review.action == Action.REVIEW
assert any(f.subcategory == "session_taint_side_effect" for f in review.findings)

All assertions pass on current dev. Observed trigger: ['scan:tool_output'].

The streaming path behaves differently

The equivalent chunked output scan is isolated and does not touch shared state:

stream_guard = Guard()
stream_result = stream_guard.scan_stream(["The answer is 42."], source=Source.TOOL_OUTPUT)
assert stream_result.safe is True
assert stream_guard.context.is_session_tainted is False

So non-streaming and streaming output scans have different session side effects for the same content. scan_stream() uses isolated=True internally; scan_output() does not.

Positive controls still work

I checked that real ingestion paths still taint and gate correctly, so this is not a broken tool gate:

  • guard.notify_taint_source("web_fetch") taints the session and check_tool_call("shell", ...) returns REVIEW.
  • guard.scan("Some RAG chunk content here.", source=Source.RETRIEVED) taints via scan:retrieved and the same side-effect call returns REVIEW.

Expected behavior

Output scanning should keep its leakage/redaction protection, but scanning my own model response should not mark the shared session as influenced by untrusted data. Session taint should come from actual untrusted ingestion: retrieved/external content or configured taint-source tools.

One possible direction, not a mandate: separate "scan this text for leakage before it leaves" from "the session has been influenced by untrusted data". Regression coverage would need both cases: benign output leaving the session clean, and real ingestion still tainting. Happy either way if maintainers would rather treat the current behavior as intended CaMeL-style conservatism and document it instead.

Environment: Python 3.13.13, Windows 11, unplug built from dev at cf0b0c7.

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