Skip to content

Report injection_ml on ScanResult when the ML layer did not load - #203

Open
HarshRajSinghania wants to merge 1 commit into
UnplugAI:devfrom
HarshRajSinghania:fix/scan-result-ml-degraded
Open

HarshRajSinghania wants to merge 1 commit into
UnplugAI:devfrom
HarshRajSinghania:fix/scan-result-ml-degraded

Conversation

@HarshRajSinghania

Copy link
Copy Markdown

Fixes #107.

Summary

Local scans now copy Guard._ml_degraded onto ScanResult when active_model was requested and injection_ml did not load. degraded is true and degraded_layers includes injection_ml.

Motivation

#107: Guard.ml_degraded was true, but guard.scan() still returned degraded=False and degraded_layers=[]. Maintainer comment on the issue: keep degrade-by-default (require_ml=True already fails closed) and wire the existing flag through with degraded_layers=["injection_ml"].

Implementation

_report_ml_degraded annotates local results from scan_request, scan_output_request, and check_tool_call, including limit and fail-closed results. Server-mode responses are left unchanged because that path reports degradation itself. An already-listed injection_ml layer is not duplicated.

Testing

  • cd sdk && uv venv --python 3.11 /tmp/unplug-venv && uv pip install --python /tmp/unplug-venv/bin/python -e . pytest
  • /tmp/unplug-venv/bin/python -m pytest tests/unit/test_ml_degraded_warning.py -q → 5 passed
  • Full make check-ci was not run (needs the repo lockfile toolchain and the broader suite).

Drafted with an AI assistant; I reviewed the diff and ran the tests above.

@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Medium risk] Marks scan results when the ML safety layer fails to load.

Fix the formatting and the lost missing-model status in incremental streams before merging.

Fix All in Claude CodeFindings

  1. P1 Formatting blocks CI ▶
  2. P1 Streams lose missing-model status ▶
  3. P2 Other scan paths lack tests ▶

Summary

The PR copies the existing missing-model flag onto local ScanResult values. It covers input scans, output scans, tool checks, limits, and exception returns while leaving server responses unchanged.

  • Fix the new formatting so CI passes.
  • Preserve the degradation fields when incremental streams rebuild results.
  • Expand tests beyond ordinary input scans.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Local scan, tool check, limit, or error] --> B[ScanResult]
  B --> C[Copy missing-model status]
  C --> D[Caller]
  C --> E[Incremental stream with reused prefix]
  E --> F[Rebuild ScanResult]
  F --> G[Degradation fields fall back to defaults]
  H[Server response] --> D
Loading

Reviews (1) · Last reviewed commit: "Report injection_ml on ScanResult when t..." · Reviewed by Greptile

Comment thread sdk/src/unplug/guard.py
Comment on lines +665 to 667
return self._report_ml_degraded(
_limit_result(
LimitViolation(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Formatting blocks CI

The new _limit_result calls are indented outside their surrounding _report_ml_degraded calls. Python accepts this inside parentheses, but ruff format --check . requires changes. CI runs that check on every supported Python version, so this PR cannot pass as written. Format both wrappers and remove the extra blank line before _report_ml_degraded.

Fix in Claude Code

Comment thread sdk/src/unplug/guard.py
if not isolated:
self._maybe_mark_session_tainted_from_scan(request.source)
return result
return self._report_ml_degraded(result)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Streams lose missing-model status

Incremental streams still lose the new degradation fields after reusing a safe prefix. StreamScanner calls this newly annotated scan_request, then passes its result to merge_suffix_result. That function builds a fresh ScanResult without degraded or degraded_layers. After an allowed chunk exceeds the overlap length, later chunks report degraded=False even though the configured ML layer never loaded. Preserve both fields in merge_suffix_result and add a test covering two streamed chunks.

Knowledge Base Used:

Fix in Claude Code

Comment on lines +78 to +86
def test_scan_result_reports_injection_ml_degraded(self) -> None:
"""Callers inspect ScanResult, not the private flag. #107."""
with patch("unplug.guard.prepare_active_model_spec", return_value=None):
guard = Guard(config=GuardConfig(active_model="tiny"))

assert guard.ml_degraded is True
result = guard.scan("some text")
assert result.degraded is True
assert result.degraded_layers == ["injection_ml"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Other scan paths lack tests

The new tests check only ordinary guard.scan results. The PR also changes output scans, tool checks, limit returns, and exception returns, but none has an assertion for the new degradation fields. Add focused tests for those paths, plus preserving another layer and avoiding a duplicate injection_ml entry. Otherwise, removing one of the new wrappers or breaking the merge would still pass these tests.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

This branch has not been deployed

No deployments
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.

ScanResult.degraded is never set

1 participant