Skip to content

feat(voice): opt-in PII redaction at the LLM egress boundary#6342

Open
mcdgavin wants to merge 1 commit into
livekit:mainfrom
mcdgavin:gmcd/pii-redaction-phase1
Open

feat(voice): opt-in PII redaction at the LLM egress boundary#6342
mcdgavin wants to merge 1 commit into
livekit:mainfrom
mcdgavin:gmcd/pii-redaction-phase1

Conversation

@mcdgavin

@mcdgavin mcdgavin commented Jul 7, 2026

Copy link
Copy Markdown

What

Adds an opt-in, provider-agnostic PII redaction contract to livekit-agents core, and wires it at the first and most important egress: the chat context sent to the LLM.

This is Phase 1 of the redaction interception contract proposed in this RFC, which unifies #6204 (in-flight STT→LLM redaction) and #6050 (at-rest transcript redaction). It is deliberately the smallest reviewable slice: one protocol, one knob, one sink.

Closes #6204.

Why

Voice agents routinely process regulated data (PCI/HIPAA/GDPR). Today the only native option is Deepgram's vendor-side redact β€” provider-specific, STT-only. Userland can hook on_user_turn_completed or override llm_node, but each project re-derives the wiring and typically misses the follow-up LLM calls in multi-step tool loops. A core-owned seam applies redaction consistently; detection stays pluggable.

Usage

from livekit.agents import AgentSession, RedactionOptions, RegexRedactor

session = AgentSession(
    llm="openai/gpt-4o-mini",
    redaction=RedactionOptions(redactor=RegexRedactor()),  # sinks={RedactionSink.LLM} by default
)

When redaction is unset (the default), nothing changes β€” the feature is entirely opt-in.

Design

  • Redact-on-egress: a redacted copy crosses the boundary; session.history keeps raw values, so a compliant local tool (e.g. a PCI-scoped payment API) can still receive the real card number while the third-party LLM never sees it.
  • Seam placement: redaction runs in AgentActivity before perform_llm_inference hands the context to llm_node β€” so it covers overridden llm_node implementations (a hook inside the default node would be silently bypassed) and re-runs on every step of a multi-step tool loop. The four realtime update_chat_ctx pushes are wired through the same helper, covering the text seam that exists even for speech-to-speech models.
  • Fail-closed: if the redactor raises or exceeds its timeout, the affected text is replaced with [REDACTED] β€” raw PII is never silently emitted.
  • Idempotent placeholders make it safe for redaction to run at multiple points over overlapping content.
  • RegexRedactor is honestly scoped: Luhn-validated card numbers, format-checked US SSNs, mod-97-validated IBANs. It's best-effort pattern matching, not a compliance guarantee, and says so in its docstring β€” free-form PII (names, addresses) is left to pluggable backends (Presidio, piiguard, vendor-side redaction) that implement the same Redactor protocol.

Testing

  • 17 unit tests (tests/test_redaction.py): validator accept/reject cases, idempotency, fail-closed on raising and hanging redactors, entities carrying spans but never raw values.
  • Canary leak tests through the real AgentSession pipeline: a sentinel card number never reaches a capturing fake LLM while history retains it raw β€” including with an overridden llm_node (the case a naive hook placement would fail), plus a no-redaction control proving the sentinel otherwise flows, and a stub realtime session asserting context pushes arrive redacted.
  • make check green (ruff, strict mypy); full --unit suite passing.

Known gaps (documented, not hidden)

  • Three side-paths send content to an LLM without passing through llm_node: the history-summarization helper (chat_context.py), the AMD classifier, and the evals judge. Mechanical follow-ups once the contract lands.
  • Live audio to speech-to-speech providers has no text seam β€” the levers there are provider zero-retention and post-hoc transcript redaction (Phase 2).

Roadmap

Phase 2 (transcript sink at the session-report egress, resolves #6050) and Phase 3 (telemetry sink) are implemented on stacked branches and will be PR'd separately once this lands.

Adds a provider-agnostic redaction contract (Redactor protocol,
RedactionOptions, RedactionSink) and an opt-in `redaction=` knob on
AgentSession. When enabled, a redacted copy of the chat context is what
crosses the LLM boundary - applied before the llm_node call so overridden
llm_node implementations are covered, and on every realtime
update_chat_ctx push - while the in-memory history keeps the raw values.

Fails closed: if the redactor raises or exceeds its timeout, the affected
text is replaced with a redaction marker instead of passing through raw.

Ships NoopRedactor (the implicit default; zero behavior change when the
option is unset) and RegexRedactor, a best-effort reference detector for
structured PII (Luhn-validated card numbers, format-checked US SSNs,
mod-97-validated IBANs) that is documented as pattern matching, not a
compliance guarantee.

Phase 1 of the redaction interception contract discussed in livekit#6050 and
livekit#6204: contract + LLM egress sink. Transcript and telemetry sinks are
declared in RedactionSink but not wired yet.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +175 to +189
for item in chat_ctx.items:
if item.type == "message":
ctx = RedactionContext(sink=sink, role=item.role)
new_content = [
await _redact_text(c, opts, ctx) if isinstance(c, str) else c for c in item.content
]
if new_content != item.content:
item = item.model_copy(update={"content": new_content})
elif item.type == "function_call_output":
ctx = RedactionContext(sink=sink, role="tool")
new_output = await _redact_text(item.output, opts, ctx)
if new_output != item.output:
item = item.model_copy(update={"output": new_output})

items.append(item)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ” FunctionCall arguments field is not redacted

The redact_chat_ctx function handles message content and function_call_output output, but skips function_call items entirely. The arguments field of FunctionCall (livekit-agents/livekit/agents/llm/chat_context.py:348) contains a JSON string that could include user-provided data (e.g., if the LLM echoes PII into tool call arguments). This is likely intentional since function call arguments are LLM-generated (and thus already seen by the LLM), but it's worth documenting the boundary explicitly β€” especially for the TELEMETRY and TRANSCRIPT sinks where the concern is data leaving the agent process rather than data reaching the LLM.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Middleware hook between STT and LLM for PII redaction STT-agnostic PII redaction for transcripts before they hit logs/storage

1 participant