feat(ENG-1084): detect-and-gate + sentence-level cleaning (v0.8.2) - #28
Merged
Conversation
…(v0.8.0) Ports the TS defender 0.8.0 "detect-and-gate" architecture to Python. Defender no longer rewrites/redacts tool-result content. `sanitized` returns the ORIGINAL payload (optionally boundary-wrapped); threats are recorded as detection evidence and blocking is expressed via `allowed`/`risk_level` only. The mutation helpers (pattern_remover, role_stripper, the composite Sanitizer) are deleted (~460 lines). Also: - default_risk_level medium -> low, plus a monotonic raise_overall_risk, so reported risk tracks the model (validated on 800 SFE payloads: 80% low / 16% medium / 4% high, matching TS; was 0% low under the medium floor). - Object-KEY injection scanning (detect_in_key); a non-destructive wide-container detection cap (analysis_truncated / coverage_degraded) replacing the lossy large-array truncation; per-field analysis-length cap (max_field_analysis_length). - Tier 2 availability: require_tier2 (fail-closed), tier2_available, warn-once (module-scoped), cold-sample-before-warmup. - Evidence-driven encoding escalation (decode then run the real pattern detector). - ReDoS bounds (markdown-link, Morse); non-finite model output -> explicit skip; onnx load-failure warn moved to the caller. BREAKING CHANGE: `sanitized` is no longer redacted/blocked — gate on `allowed`. `default_risk_level` defaults to `low`. The `block_high_risk` sanitizer option and the `pattern_remover` / `role_stripper` / `sanitizer` modules are removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Ports the “detect-and-gate” architecture to the Python Defender SDK so tool-result processing becomes detection-only: the returned sanitized payload preserves the original content, while threat signals are recorded as evidence and gating is expressed via allowed.
Changes:
- Reworks Tier 1 tool-result traversal to detect threats without mutating content; adds key scanning and coverage/analysis caps.
- Updates the risk model (default risk floor to
low) and surfaces Tier 2 availability + coverage degradation inDefenseResult. - Removes mutation-based sanitizer modules and updates tests/docs; includes ReDoS hardening and Tier 2 robustness improvements.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_sanitizers.py | Removes tests tied to deleted mutation sanitizers; retains unit coverage for remaining detection helpers. |
| tests/test_integration.py | Updates integration assertions to “detect-and-gate” semantics (content preserved; evidence + allowed gate). |
| src/stackone_defender/types.py | Adjusts defaults/metadata to reflect detection evidence; adds tier2_available and coverage_degraded. |
| src/stackone_defender/sanitizers/sanitizer.py | Deleted: composite mutation sanitizer implementation removed. |
| src/stackone_defender/sanitizers/role_stripper.py | Deleted: role-marker stripping module removed. |
| src/stackone_defender/sanitizers/pattern_remover.py | Deleted: pattern redaction/removal module removed. |
| src/stackone_defender/sanitizers/encoding_detector.py | ReDoS hardening for Morse detection gate. |
| src/stackone_defender/sanitizers/init.py | Re-exports shift toward detection-only helpers (no mutation/redaction exports). |
| src/stackone_defender/core/tool_result_sanitizer.py | Major rewrite to detect-only traversal, evidence recording, key scanning, and capped analysis without dropping data. |
| src/stackone_defender/core/prompt_defense.py | Adds Tier 2 “require/fail-closed”, warn-once degraded behavior, and coverage_degraded plumbing. |
| src/stackone_defender/config.py | Introduces default per-field analysis length cap for Tier 1 (ReDoS/cost guard). |
| src/stackone_defender/classifiers/tier2_classifier.py | Treats non-finite model outputs as a classifier skip (not a benign score). |
| src/stackone_defender/classifiers/patterns.py | ReDoS-hardens Morse + markdown hidden-instruction regexes. |
| src/stackone_defender/classifiers/onnx_classifier.py | Moves repeated ONNX load-failure warning responsibility to the caller. |
| README.md | Documents breaking “detect-and-gate” semantics and updated defaults/behavior. |
Suppressed comments (3)
src/stackone_defender/core/tool_result_sanitizer.py:212
- SizeMetrics.object_count is incremented twice for dicts: update_size_metrics() already increments object_count in _sanitize_value(), and _sanitize_object() increments it again. This doubles object_count in metadata.size_metrics.
metadata.size_metrics.object_count += 1
src/stackone_defender/core/tool_result_sanitizer.py:252
entries = list(obj.items())allocates an extra list of all key/value pairs; for large paginated responses this can be a significant and unnecessary memory hit. Uselen(obj)for the scan limit and iterateobj.items()directly.
entries = list(obj.items())
scan_limit = self._detection_scan_limit(len(entries), metadata)
for i, (key, val) in enumerate(entries):
entry_detect = detect and i < scan_limit
src/stackone_defender/core/tool_result_sanitizer.py:278
entries = list(obj.items())duplicates all key/value pairs in memory before iterating. For wide wrapped payloads this adds avoidable overhead; you can derive the scan limit fromlen(obj)and iterateobj.items()directly.
entries = list(obj.items())
scan_limit = self._detection_scan_limit(len(entries), metadata)
for i, (key, val) in enumerate(entries):
entry_detect = detect and i < scan_limit
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…pping Adversarial review of #28: - HIGH: warmup_tier2() called warmup() unguarded, so it crashed app startup when the ONNX extra was missing and require_tier2=False (the fail-open default), contradicting the README and the defend path. Route it through _handle_tier2_unavailable (fail-closed when required, warn-once otherwise). - MEDIUM: `type(value) is dict` skipped dict SUBCLASSES (OrderedDict, bson.SON) entirely — bypassing DANGEROUS_KEYS prototype-pollution stripping AND detection. In Python the TS plain-object check maps to `isinstance(value, dict)` (datetime/set are still non-dicts and pass through). Restore isinstance. Regression tests for both, plus non-dict passthrough (datetime/set). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in README The README's DefenseResult block was missing the 0.7.4 telemetry fields (phase_timings, tier2_stats, tier1_ms, cold_load), the 0.8.0 signals (tier2_available, coverage_degraded), and several pre-existing fields (tier2_raw_score, tier2_aux_score, tier2_multihead_blocked, tier3). Also document the require_tier2 option. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports the TS decorative-output false-positive fix. Box-drawing rules (`─`), `===`, `---`, `###` tokenize one-token-per-char, so a rule line is ~85% one repeated token; under mean pooling that lands off-distribution and the head returns an arbitrary, often high score — flagging benign terminal output as an injection, higher than a real one. Collapse 4+ repeats of the same non-word char to 3 before classification (classifier input only — the payload is never modified), via a single _normalize_for_classification() routed through all four classify paths. Python's re \w is Unicode-aware, so accented letters are preserved. Realistic decoration-heavy logs now score low; the pure-decoration corner is reduced (~0.97 -> ~0.70) but stays off-distribution — the argument for the token-degeneracy (OOD) guard follow-up. Adds regression fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the TypeScript defender guard. Decorative terminal output (repeated box-drawing/rule chars) tokenizes to a few repeated tokens and, under mean pooling, sits off-distribution where the model's score is arbitrary — a bare rule line outscored real injections (~0.97). Damp such rows to a benign 0 so they drop out of the max, instead of trusting the score. Applied at the shared onnx seam (classify_pair + classify_batch_pair), reusing the ids the model already runs on — no extra tokenization — so every Tier 2 path is covered. Guard fires only when all three hold over the content tokens: 1. most-frequent token covers >= 2/3 (degeneracy_max_token_share), AND 2. <= 4 distinct tokens, AND 3. the dominant token is not [UNK]. Factor 2 blocks a padding attack: appending many copies of any repeated token (`---` runs, or the word "the") would otherwise cross the share threshold and damp an attack-bearing row to 0. Padding adds vocabulary but cannot remove the attack's own, and an injection needs > 4 distinct tokens. Factor 3 blocks a homoglyph attack: fullwidth / zero-width / other OOV characters collapse to repeated [UNK], which satisfies 1 and 2 but is the signature of encoding evasion — more suspicious, not less. It stops the guard suppressing those rows (detection of them is a separate Tier-1 unicode- normalization gap, unchanged here). Adds regression fixtures for both engineered bypasses (assert not damped). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the TypeScript change. Tier-2's classifier input now runs through normalize_unicode (NFKC) in _normalize_for_classification, so fullwidth / math-styled obfuscation tokenizes as real words (~0.95) instead of [UNK] (~0.48). Defense-in-depth: Tier 1 already NFKC-folds these. Analysis-only — _normalize_for_classification never mutates the returned payload. Cross-script confusables (Greek/Cyrillic homoglyphs) are intentionally NOT folded: a curated map is whack-a-mole (Armenian/Georgian remain) and NFKC already covers the corpus-attested case (fullwidth). Left as a documented known-gap. FPR gate: 940 benign SFE payloads, zero new false positives from NFKC folding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inal
Mirrors the TypeScript change. Restores sanitize-by-default at SENTENCE
granularity (the old phrase-level regex was ~94% ineffective) and returns BOTH:
- `sanitized`: sentence-level cleaned copy — high-scoring sentences dropped
within high-risk fields, whole-field block for a single sentence, role
markers stripped from survivors, boundary-wrapped when annotate_boundary.
- `original`: the untouched content.
New `sanitize_content` option (default True); False = pure detect-and-gate.
Cleaning runs after Tier 2 (new core/sentence_cleaner.py) reusing
classify_chunks_batch for per-sentence scores, keyed on the un-damped
per-string scores. Restores role_stripper for in-survivor defense-in-depth.
Both sync and async defend paths wired.
Verdict-neutral (detection/verdict unchanged). 287 tests pass, ruff clean.
Known gap (deferred): a diluted list injection can be demoted to low by density
damping and never flagged/cleaned — per-string gating fix is a follow-up.
Treat `sanitized` as best-effort; gate on `allowed`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ield block Clean the `sanitized` copy only when the aggregate verdict is high/critical (not per-string), so a density-damped low verdict never rewrites content — risk-low now always means sanitized == original. Never emit a whole-field block marker: a single-sentence field is left as original (can't isolate a bad sentence, and benign opaque tokens read as one), and an all-sentences-high field drops to empty. Mirrors the TS change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er-1 detections Mirror of the TS change: fields_sanitized now lists exactly the leaf paths the return-both cleaner changed (empty under sanitize_content=False or without Tier 2), instead of Tier-1 detect-only methods_by_field. Tier-1 detection stays in detections/patterns_by_field; the Tier-1 signal is retained internally for the block decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…al field; fix stale sanitized=original lines Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of the TS guard: when kept == all sentences, return raw instead of reconstructing via " ".join(kept), avoiding spurious sanitized != original diffs on fields where nothing is dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the version explicitly: the org release-please config bumps feat to a patch pre-1.0, so a plain feat commit would land 0.7.5. This keeps parity with the TS defender 0.8.0 release without a breaking marker. Release-As: 0.8.0 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… copy A silent join hid mid-content cuts from consumers that read only sanitized. Replace each contiguous high-risk run with one inline marker, keeping surrounding sentences in place; an all-high field becomes just the marker. Original stays untouched; fields_sanitized unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
update_size_metrics already counts each container; _sanitize_array/_sanitize_object incremented again, doubling array_count/object_count on the normal path (telemetry only — the counts don't gate traversal). Drop the redundant increments and count the direct _sanitize_array call sites that bypass update_size_metrics. Also iterate obj.items() directly instead of materialising list(obj.items()) on wide payloads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d_count
Port of the TS review fixes (StuBehan, #85):
- Strings inside a risky field's array ({"name": [INJ]}) skipped Tier 1.
Route risky-field strings to _sanitize_string_field, preserving the
risky-field allowlist.
- Add detected_field_count (len of patterns_by_field) so downstream has a
first-class threat-count signal instead of fields_sanitized.length.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…anning Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port of TS #85: replace the per-container 100-item cap (per-container, bypassable, blinded normal >1000-item payloads) with the existing call-scoped max_size byte budget. skip_large_arrays/large_array_threshold kept as deprecated, off-by-default opt-ins for the legacy cap (non-breaking). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e_degraded Port of TS #85 docs: migration note that content past the max_size detection budget is returned unanalysed (not dropped); coverage_degraded is None (not False) when complete, and Tier 2 still scans every string when enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…data-loss bug Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… field Port of TS: remove the return-both raw payload — sanitized + allowed is the full surface; consumers keep their own raw copy. Internal detect-only value unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on uncaught) Port of TS #85 review fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hiskudin
added a commit
that referenced
this pull request
Aug 19, 2026
The non-squash merge of #28 left the release PR at 0.8.0 with the old breaking footer. Correct the version to 0.8.2 and tidy the changelog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ports the TS 0.8.2 detect-and-gate + sentence-level cleaning architecture (TS #85) to Python. Defender no longer mutates content in place; it returns
sanitized— a sentence-cleaned copy of the flagged fields (restores the 0.7.4 cleaning) — plus theallowedverdict.sanitize_content=False→sanitizedis the input verbatim.Blocking is
allowed. The compositeSanitizerandpattern_removerare deleted;role_stripperis kept for the marker strip.Behavior changes (not breaking) / Migration
Feature + bug-fix release — no released (0.7.4) consumer changes code:
sanitizedis a sentence-cleaned copy (its 0.7.4 meaning), now at sentence granularity; fires only at aggregate high/critical.sanitize_content=False→ input verbatim.default_risk_levelnowlow(wasmedium) — reported risk tracks the model; blocking still keys onallowed.block_high_riskoption;pattern_remover/sanitizermodules.Changes
tool_result_sanitizer.py) — detect-only; evidence-basedfields_sanitized/methods_by_field/patterns_by_field; top-level string + non-plain-object handling.sentence_cleaner.py) — gated high/critical; drops offending sentences and leaves a[CONTENT SANITISED]marker where each run was cut (mid-content drops stay visible), keeps the rest; single-sentence → unchanged, all-high → just the marker; verbatim when nothing dropped.fields_sanitized= fields the cleaner changed (was Tier-1 detect-only). API-visible semantic change.coverage_degraded) + per-field cap.require_tier2,tier2_available, warn-once.Post-review fixes (StuBehan, ported from #85)
{"name": [INJ]}) — previously fell through_sanitize_valueto the pass-through. Risky-field allowlist preserved.detected_field_count(keys ofpatterns_by_field) — first-class threat-count signal, sincefields_sanitizednow means cleaner-changed fields.obj.items()directly instead oflist(obj.items())on wide payloads.max_size), replacing the per-container 100-item cap (per-container, bypassable, blinded normal >1000-item payloads).skip_large_arrays/large_array_thresholdkept as deprecated, off-by-default opt-ins (non-breaking).Validation
ruffclean.[CONTENT SANITISED]marker at the cut); verdict FPR byte-identical to detect-only.AgentShield 537 (vs published v0.7.4, both
onnxruntime@1.21.0— by parity with TS #85)All other categories identical — verdict-neutral. Real gates: Claude Code plugin repro 0.951 → 0.199 + connector FPR above.
Version
→ 0.8.2, pinned via
Release-As(below) to match the TS package (which landed on 0.8.2 after two publish-retry patch bumps). The org release-please config bumpsfeatto a patch pre-1.0, so the version is pinned explicitly. Squash-merge so the cleanfeat(ENG-1084):title is the release commit and the old!footer is dropped.Release-As: 0.8.2
🤖 Generated with Claude Code